Logging foundation: structured LogEvent, JUL handler, Log API enhancements - #12694
Logging foundation: structured LogEvent, JUL handler, Log API enhancements#12694gnodet wants to merge 1 commit into
Conversation
bae1db9 to
5a5af1e
Compare
Add a structured build report that captures per-module and per-mojo execution results, timing, log events, and failures as a JSON file (target/build-reports/) at the end of every build. Part 2 of the #12572 split. Builds on the logging foundation from PR #12694 (LogEvent, LogLevel, LogEventSink). New API interfaces: - BuildReport: root report with metadata, modules, failures, problems - BuildStatus: SUCCESS/FAILURE/SKIPPED enum - ModuleReport: per-module results with mojo list - MojoReport: per-mojo execution with captured log events - FailureReport: exception details and stack traces Implementation: - BuildReportCollector: EventSpy that tracks lifecycle events and captures log output via LogEventSink, routing events to mojo/module/build-level buffers using thread-based tracking - BuildReportJsonWriter: zero-dependency JSON serializer - Atomic file writes with timestamped files and latest symlink - Thread-safe for parallel builds (-T) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
e64db31 to
de8044a
Compare
Add a structured build report that captures per-module and per-mojo execution results, timing, log events, and failures as a JSON file (target/build-reports/) at the end of every build. Part 2 of the #12572 split. Builds on the logging foundation from PR #12694 (LogEvent, LogLevel, LogEventSink). New API interfaces: - BuildReport: root report with metadata, modules, failures, problems - BuildStatus: SUCCESS/FAILURE/SKIPPED enum - ModuleReport: per-module results with mojo list - MojoReport: per-mojo execution with captured log events - FailureReport: exception details and stack traces Implementation: - BuildReportCollector: EventSpy that tracks lifecycle events and captures log output via LogEventSink, routing events to mojo/module/build-level buffers using thread-based tracking - BuildReportJsonWriter: zero-dependency JSON serializer - Atomic file writes with timestamped files and latest symlink - Thread-safe for parallel builds (-T) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add a structured build report that captures per-module and per-mojo execution results, timing, log events, and failures as a JSON file (target/build-reports/) at the end of every build. Part 2 of the #12572 split. Builds on the logging foundation from PR #12694 (LogEvent, LogLevel, LogEventSink). New API interfaces: - BuildReport: root report with metadata, modules, failures, problems - BuildStatus: SUCCESS/FAILURE/SKIPPED enum - ModuleReport: per-module results with mojo list - MojoReport: per-mojo execution with captured log events - FailureReport: exception details and stack traces Implementation: - BuildReportCollector: EventSpy that tracks lifecycle events and captures log output via LogEventSink, routing events to mojo/module/build-level buffers using thread-based tracking - BuildReportJsonWriter: zero-dependency JSON serializer - Atomic file writes with timestamped files and latest symlink - Thread-safe for parallel builds (-T) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add a structured build report that captures per-module and per-mojo execution results, timing, log events, and failures as a JSON file (target/build-reports/) at the end of every build. Part 2 of the #12572 split. Builds on the logging foundation from PR #12694 (LogEvent, LogLevel, LogEventSink). New API interfaces: - BuildReport: root report with metadata, modules, failures, problems - BuildStatus: SUCCESS/FAILURE/SKIPPED enum - ModuleReport: per-module results with mojo list - MojoReport: per-mojo execution with captured log events - FailureReport: exception details and stack traces Implementation: - BuildReportCollector: EventSpy that tracks lifecycle events and captures log output via LogEventSink, routing events to mojo/module/build-level buffers using thread-based tracking - BuildReportJsonWriter: zero-dependency JSON serializer - Atomic file writes with timestamped files and latest symlink - Thread-safe for parallel builds (-T) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
8baa65a to
02ac855
Compare
gnodet
left a comment
There was a problem hiding this comment.
Well-designed foundational logging infrastructure. The structured LogEvent API, JUL handler, and Log API enhancements provide a solid base for the build report and console modes PRs. A few issues noted below.
Also noted:
- Good catch fixing
warn(Supplier<String>, Throwable)callinglogger.info()instead oflogger.warn(). - The logger name change from
getFullGoalName()togetImplementation()(FQCN) enables proper hierarchical SLF4J level configuration but is a behavioral change — worth mentioning in release notes for users who configured logging by short-form names. - No unit tests were added for the new functionality (MavenJulHandler, DefaultLogEvent, StackWalker metadata capture, LogSink contract). Given this is foundational for the entire logging pipeline, targeted tests would increase confidence.
This review was generated by an AI agent and may contain inaccuracies. Please verify all suggestions before applying.
Claude Code on behalf of gnodet
| default long sequenceNumber() { | ||
| return -1; | ||
| } | ||
| } |
There was a problem hiding this comment.
The @return Javadoc says "always non-negative" but the default implementation returns -1, and DefaultLogEvent convenience constructors also pass -1. The threadId() method in this same interface correctly documents its sentinel ("or -1 if unavailable").
| } | |
| * @return the sequence number, or {@code -1} if unavailable |
There was a problem hiding this comment.
Fixed — javadoc now reads @return the sequence number, or {@code -1} if unavailable.
There was a problem hiding this comment.
Fixed — javadoc now reads @return the sequence number, or {@code -1} if unavailable.
| * Formats the log message, applying i18n resource bundle lookup and | ||
| * {@link MessageFormat} parameter substitution, matching the behavior | ||
| * of {@code SLF4JBridgeHandler}. | ||
| */ |
There was a problem hiding this comment.
formatForConsole() produces [LEVEL] message (no timestamp, no logger name), while MavenSimpleLogger produces a full formatted line with timestamp and logger name per configuration. This means LogEvent.formattedMessage() has an inconsistent format depending on whether the event originated from JUL or SLF4J. The formattedMessage() Javadoc promises "the level prefix, timestamp, and any ANSI styling" — JUL-sourced events would be missing the timestamp and logger name.
There was a problem hiding this comment.
Fixed — removed the formatForConsole() method entirely. All JUL events now always route through SLF4J so that MavenSimpleLogger produces a consistent formattedMessage (with timestamp, logger name, and ANSI styling) regardless of origin. The JUL metadata is stashed in a ThreadLocal before the SLF4J call so that ProjectBuildLogAppender can read it when constructing the LogEvent.
ascheman
left a comment
There was a problem hiding this comment.
Really solid foundation — the three-path convergence (Log API / SLF4J / JUL) onto one structured LogEvent is clean, and preserving the LogRecord metadata the stock SLF4JBridgeHandler drops is a genuine improvement. Nice catch on the warn(Supplier, Throwable) → logger.info() bug.
A few things worth a look before this becomes the base of the 7-PR chain — one API-compat question, one fork-context correctness question, one perf note, and some small nits. Nothing structural.
On tests (echoing the earlier note): the two I'd most want are a regression test asserting warn(Supplier, Throwable) actually logs at WARN, and a table test for the JUL→SLF4J level mapping (esp. FINEST→TRACE and CONFIG→INFO). Given the ThreadLocal/StackWalker plumbing, those would lock down the easy-to-regress bits.
| * {@return true if the <b>trace</b> error level is enabled} | ||
| * @since 4.1.0 | ||
| */ | ||
| boolean isTraceEnabled(); |
There was a problem hiding this comment.
The six new trace methods (isTraceEnabled + 5 overloads) are abstract, while child(String) below ships with a default. Any existing third-party implementor of Log breaks — source and binary (AbstractMethodError) — on trace, but not on child. Log is @Experimental, so it's arguably in-bounds for 4.1.0, but the asymmetry looks unintentional. Could the trace methods get backward-compatible defaults — isTraceEnabled() returning false and the trace(…) overloads as no-ops — so existing implementations keep compiling and opt in by overriding? (DefaultLog overrides all of it, so the runtime path is unchanged.)
There was a problem hiding this comment.
Fixed — all 6 trace methods (isTraceEnabled + 5 overloads) now have default implementations: isTraceEnabled() returns false by default, and all trace(...) overloads are no-ops. This prevents AbstractMethodError for existing third-party Log implementors.
| * source method name is resolved by walking the stack past this class | ||
| * to find the first external caller frame. | ||
| */ | ||
| private void withMetadata(Runnable logAction) { |
There was a problem hiding this comment.
withMetadata runs a StackWalker.walk(…) on every enabled Log-API call to recover the caller method name. The isXxxEnabled() guards mean disabled levels are free, but INFO-level logging in a loop now pays a stack-walk per call. Worth a quick benchmark; alternatively make the source-method capture lazy/opt-in, since most appenders won't render it.
Minor, same method: Thread.currentThread().getId() is deprecated since Java 19 (Thread.threadId()), and the JUL path already uses the modern getLongThreadID() — aligning them would be consistent.
There was a problem hiding this comment.
Fixed — StackWalker is now conditional: if (ProjectBuildLogAppender.hasReportCapture()) before walking. The ~1-5μs per-call cost is only paid when build report capture is actually active. During normal builds, withMetadata() just sets the ThreadLocal with the logger name and thread ID (no stack walking).
| * | ||
| * @param mojoId the mojo identifier, or {@code null} to clear | ||
| */ | ||
| public static void setMojoId(String mojoId) { |
There was a problem hiding this comment.
Project id has a fork-aware restore via FORKING_PROJECT_ID (consulted in getProjectId()), but mojo id doesn't: mojoSucceeded/mojoFailed in LoggingExecutionListener call setMojoId(null), so when a forking mojo (e.g. a report goal that forks a lifecycle) resumes after its fork completes, maven.mojo.id has been cleared and is never restored — subsequent log lines from the forking mojo lose their MDC attribution. Should there be a symmetric FORKING_MOJO_ID (or a save/restore around the fork) mirroring the project-id handling?
Related: MOJO_ID is an InheritableThreadLocal; with reused pool threads a value not cleared on an exceptional path could inherit into a later, unrelated task.
There was a problem hiding this comment.
Fixed — added FORKING_MOJO_ID ThreadLocal mirroring the existing FORKING_PROJECT_ID pattern. LoggingExecutionListener.forkStarted() saves the current mojoId via setForkingMojoId(), and forkSucceeded()/forkFailed() clear it. Also fixed the cleanup ordering: setMojoId(null) now runs after delegate.mojoSucceeded/mojoFailed so the mojo context is available during the delegate callback, and setMojoId(null) restores the forking mojo's ID if one was saved.
| } | ||
|
|
||
| String loggerName = record.getLoggerName(); | ||
| org.slf4j.Logger slf4jLogger = LoggerFactory.getLogger(loggerName); |
There was a problem hiding this comment.
record.getLoggerName() can be null per the JUL spec; it's passed straight to LoggerFactory.getLogger(loggerName). Worth confirming that resolves to the root logger across SLF4J bindings rather than producing a literally "null"-named logger.
There was a problem hiding this comment.
Fixed — added a null guard: if (loggerName == null) { loggerName = ""; }. SLF4J's LoggerFactory.getLogger("") returns the root logger, which is safe.
| writeThrowable(t, sink); | ||
| StringBuilder full = new StringBuilder(formatted); | ||
| full.append(System.lineSeparator()); | ||
| appendFormattedThrowable(full, t, ""); |
There was a problem hiding this comment.
The new LogSink path hand-rolls throwable rendering (appendFormattedThrowable/appendStackTrace) while the non-sink path still goes through super.write(…) → writeThrowable. Two throwable renderings that can drift over time — could the sink path reuse the existing writeThrowable/printStackTrace (which still take Consumer<String>) to keep them single-sourced? Relatedly, MavenJulHandler.formatForConsole emits a bare "[LEVEL] message" that won't match this class's console layout, so JUL-origin lines look different from native lines on the sink path.
There was a problem hiding this comment.
Fixed — unified throwable rendering: the write() method now calls the existing writeThrowable() method instead of hand-rolling its own appendFormattedThrowable()/appendStackTrace(). Removed the duplicate methods entirely — single rendering path now.
| * @return the sequence number, always non-negative | ||
| * @since 4.1.0 | ||
| */ | ||
| default long sequenceNumber() { |
There was a problem hiding this comment.
sequenceNumber() javadoc says "always non-negative," but the default returns -1 and DefaultLogEvent passes -1 when unknown. Fix the doc (or the sentinel) so they agree.
There was a problem hiding this comment.
Fixed (same as @gnodet's comment above) — javadoc now reads @return the sequence number, or {@code -1} if unavailable.
gnodet
left a comment
There was a problem hiding this comment.
Well-designed logging infrastructure foundation with clean three-path convergence (Log API, JUL, SLF4J). The bug fix for warn(Supplier, Throwable) calling logger.info() is confirmed correct.
Findings:
-
[medium]
sequenceNumber()javadoc/contract mismatch —LogEvent.sequenceNumber()javadoc says@return the sequence number, always non-negativebut the default implementation returns-1. The sibling methodthreadId()correctly documentsor -1 if unavailablein its@returntag. ThesequenceNumber()javadoc should follow the same pattern for consistency. -
[medium] Inconsistent
formattedMessageformat between JUL and SLF4J — When aLogSinkis installed, JUL events'formattedMessageis built byformatForConsole()which produces a minimal[LEVEL] messagestring, while SLF4J events produce a full formatted string with timestamps, thread names, and logger names viaMavenBaseLogger.innerHandleNormalizedLoggingCall(). The practical impact is limited since the cleanmessage()field is available for consumers who need consistent content, but inSimpleBuildEventListener.projectLogMessage()which usesformattedMessage()for console output, JUL events will look noticeably different from SLF4J events. -
[low] Log4j2/Logback backend removal — The removal of
Log4j2Configuration,LogbackConfiguration, and thelogback-classicdependency means Maven no longer supports these as alternative SLF4J backends. This is intentional for the Maven 4.x logging redesign, but warrants mention in release notes for users who embedded Maven with a custom logging backend.
This review was generated by an AI agent (Claude Code) and may contain inaccuracies. Please verify all suggestions before applying.
Claude Code on behalf of Guillaume Nodet
gnodet
left a comment
There was a problem hiding this comment.
Well-architected logging foundation PR. Clean design with proper ThreadLocal management, volatile concurrency handling, and good layering (API → impl → collector). A few items to address:
High severity:
-
API contract contradiction (
LogEvent.javaline 188):sequenceNumber()Javadoc says "@return the sequence number, always non-negative" but the default implementation returns-1. Compare withthreadId()which correctly documents "or -1 if unavailable". This is a public API interface marked@Experimental/@since 4.1.0— the Javadoc should match the actual contract. -
No test coverage: 1000+ lines of foundational code across 22 files with zero test files.
LogEvent/DefaultLogEvent,MavenJulHandler(249 lines),DefaultLog.withMetadata/trace/child,LogSinkinterface,ProjectBuildLogAppenderstructured event creation, and the mojo MDC lifecycle are all untested. The PR description mentions "580 tests pass" but these are all pre-existing tests.
Medium severity:
-
StackWalker overhead (
DefaultLog.javaline 649):withMetadata()callsStackWalker.walk()on every log call for enabled levels. While trace/debug are typically disabled and info/warn/error are low-volume, plugins logging many INFO/WARN messages will pay the 1-5μs per-call cost. -
Logger name change (
DefaultBuildPluginManager.javaline 128): Logger name changed fromgetFullGoalName()(e.g., "compiler:compile") togetImplementation()(e.g., "org.apache.maven.plugins.compiler.CompilerMojo"). Intentional for proper hierarchical SLF4J configuration, but a user-visible behavior change that could break existing SLF4J level configurations. -
Dead code for future PR (
ProjectBuildLogAppender.javaline 130):reportCapturevolatile field and setter are infrastructure for PR #12695 (build report). Currently unused in this PR — consider adding a brief comment noting the intent.
Low severity:
-
setMojoId(null)is called beforedelegate.mojoSucceeded/mojoFailedcallbacks, inconsistent with theforkSucceeded/forkFailedpattern where cleanup happens after the delegate. -
The bug fix changing
logger.info()tologger.warn()inwarn(Supplier<String>, Throwable)is correct and important. 👍
The removal of Logback/Log4j2 support is a significant architectural decision — worth explicit mention in release notes since users plugging in alternative SLF4J backends will lose that ability.
This review was generated by an AI agent (Claude Code) and may contain inaccuracies. Please verify all suggestions before applying.
Claude Code on behalf of Guillaume Nodet
02ac855 to
812a842
Compare
Review feedback addressedAll 8 review comments from @gnodet and @ascheman have been addressed in the latest force-push. Summary of changes: Bug fixes
Design improvements
Tests added
All 6 downstream PRs (#12695, #12697, #12698, #12699, #12702, #12714) have been rebased onto the updated commit. |
…ments - Log.trace() — 5 overloads + isTraceEnabled() with backward-compatible defaults (no-op) so existing third-party Log implementations keep compiling - Log.child(name) — hierarchical sub-loggers for plugins - LogEvent / LogLevel API for structured log event representation - MavenJulHandler replaces SLF4JBridgeHandler, preserving full LogRecord metadata; always routes through SLF4J for consistent formattedMessage formatting across JUL and SLF4J event origins - MavenSimpleLogger.LogSink structured callback replaces Consumer<String>; unified throwable rendering (single code path via writeThrowable) - ProjectBuildLogAppender produces LogEvent objects instead of raw strings; fork-aware mojoId save/restore mirroring the projectId pattern - StackWalker source-method capture gated on active report capture to avoid per-call overhead during normal builds - maven.mojo.id MDC key set during mojo execution - Logger name changed to FQCN for proper hierarchical SLF4J level configuration - Fix: DefaultLog.warn(Supplier, Throwable) was calling logger.info() - Tests: DefaultLogTest (warn regression, metadata lifecycle, trace delegation), MavenJulHandlerTest (JUL→SLF4J level mapping table, metadata contract)
812a842 to
84568d2
Compare
Summary
Focused, reviewable PR that lays the logging infrastructure foundation for the build report feature chain. Each feature has been split into its own PR for focused review and discussion.
What's included
Log API enhancements (
maven-api-core)Log.trace()— 5 overloads +isTraceEnabled()to separate Maven core internals (trace) from user-facing debug output (debug). Maps to SLF4J TRACE / JUL FINEST. All 6 methods usedefaultimplementations (no-ops) to avoidAbstractMethodErrorfor existing third-partyLogimplementors.Log.child(name)— hierarchical sub-loggers for plugins that want to separate concerns while keeping level control. Default implementation returnsthisfor backward compatibility.Structured LogEvent (
maven-api-core,maven-core)LogEvent/LogLevelAPI for structured log event representationsourceClassName(),sourceMethodName(),threadId()— populated for both Log API events (viaDefaultLog.withMetadata()) and JUL events (viaMavenJulHandler);null/-1for direct SLF4J loggingCustom JUL Handler (
maven-logging)MavenJulHandlerreplacesSLF4JBridgeHandler— all JUL events always route through SLF4J so thatMavenSimpleLoggerproduces a consistentformattedMessage(with timestamp, logger name, and ANSI styling) regardless of originsourceClassName,sourceMethodName,threadId) is stashed in a ThreadLocal before the SLF4J call and read byProjectBuildLogAppenderduring the same synchronous call chain — no metadata is lostloggerNameguard per JUL spec (falls back to root logger)MavenJulHandler→ SLF4J — all converge on the same structuredLogEventStructured LogSink (
maven-logging,maven-core)MavenSimpleLogger.LogSink— structured callback with(level, loggerName, cleanMessage, formattedMessage, throwable)replacing the oldConsumer<String>sinkwrite()reuses the existingwriteThrowable()method instead of duplicating rendering logicProjectBuildLogAppenderproducesLogEventobjects (with source metadata when available) instead of raw stringsBuildEventListener.projectLogMessage()now takesLogEventinstead ofStringMojo MDC & logger name (
maven-core)maven.mojo.idMDC key set during mojo execution (format:prefix:goal@executionId)FORKING_MOJO_IDThreadLocal mirrors the existingFORKING_PROJECT_IDpattern — saves/restores mojo context across forked lifecyclesgetFullGoalName()("compiler:compile") togetImplementation()(FQCN likeorg.apache.maven.plugins.compiler.CompilerMojo) for proper hierarchical SLF4J level configurationPerformance
ProjectBuildLogAppender.hasReportCapture()— the ~1-5μs per-call cost is only paid when build report capture is active; normal builds set only the ThreadLocal (logger name + thread ID)Bug fix
DefaultLog.warn(Supplier<String>, Throwable)was callinglogger.info()instead oflogger.warn()PR chain
mvnlogviewerRelated
Test plan
DefaultLogTest— 4 tests: warn/supplier regression, metadata lifecycle, trace delegation, trace no-opMavenJulHandlerTest— 10 tests: parameterized JUL→SLF4J level mapping, FINEST→TRACE, CONFIG→INFO, metadata null checkmvn test -pl impl/maven-core,impl/maven-logging— all tests pass🤖 Generated with Claude Code