This is an automated email from the ASF dual-hosted git repository. pkarwasz pushed a commit to branch fix/2.x/3873-throwable-converter in repository https://gitbox.apache.org/repos/asf/logging-log4j2.git
commit d944e8085a8f92b5d5f97d58fac76b99f8341e76 Author: Piotr P. Karwasz <pkarwasz-git...@apache.org> AuthorDate: Sun Sep 7 21:54:42 2025 +0200 fix: make `%ex` behavior context-independent Historically, throwable pattern converters (`%ex`, `%xEx`, etc.) behaved in a context-sensitive way: * If the **preceding formatter’s expansion** did not end with whitespace, the converter automatically inserted a space before rendering the exception. In version `2.25.0`, this was changed to insert a **newline** instead of a space, but the behavior was still dependent on surrounding context. #### What this change does This PR removes the context-dependent behavior altogether and makes `%ex` expansion fully predictable, while remaining backward-compatible: * When `%ex` is **added implicitly** because `alwaysWriteExceptions=true`: * If the pattern already ends with `%n`, a plain `%ex` is appended. * Otherwise, `%notEmpty{%n%ex}` is appended. This ensures exceptions are always clearly separated from the main log message by a newline, without adding extra characters when no exception is present. * When `%ex` is **explicitly included** in the pattern by the user: * Its expansion is rendered exactly as written in the pattern. * It will **never** prepend a newline on its own. #### Why * Eliminates confusing context-sensitive behavior. * Makes output consistent and predictable. * Preserves legacy expectations by only modifying implicitly added `%ex`. Closes #3873 --- .../log4j/core/pattern/PatternParserTest.java | 37 ++++++++++++++++++++++ .../pattern/RootThrowablePatternConverterTest.java | 4 +-- .../pattern/ThrowablePatternConverterTest.java | 6 ++-- .../logging/log4j/core/pattern/PatternParser.java | 21 +++++++++++- .../core/pattern/ThrowableStackTraceRenderer.java | 10 ------ .../VariablesNotEmptyReplacementConverter.java | 3 +- .../.2.x.x/3873_throwable_converter_new_line.xml | 12 +++++++ 7 files changed, 77 insertions(+), 16 deletions(-) diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/PatternParserTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/PatternParserTest.java index f493e25bb6..7f71eb0bc0 100644 --- a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/PatternParserTest.java +++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/PatternParserTest.java @@ -16,6 +16,7 @@ */ package org.apache.logging.log4j.core.pattern; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -23,6 +24,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.Calendar; import java.util.List; +import java.util.stream.Stream; import org.apache.logging.log4j.Level; import org.apache.logging.log4j.MarkerManager; import org.apache.logging.log4j.core.LogEvent; @@ -40,6 +42,8 @@ import org.apache.logging.log4j.util.StringMap; import org.apache.logging.log4j.util.Strings; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; class PatternParserTest { @@ -369,6 +373,39 @@ class PatternParserTest { validateConverter(formatters, 1, "Date"); } + static Stream<String> testAlwaysWriteExceptions_ensuresPrecededByNewline() { + return Stream.of("", "%m", "%n", "%m%n"); + } + + @ParameterizedTest + @MethodSource + void testAlwaysWriteExceptions_ensuresPrecededByNewline(final String pattern) { + final List<PatternFormatter> formatters = parser.parse(pattern, true, false, false); + assertNotNull(formatters); + if (pattern.endsWith("%n")) { + // Case 1: the original pattern ends with a new line, so the last converter is a ThrowablePatternConverter + assertThat(formatters).hasSizeGreaterThan(1); + final LogEventPatternConverter lastConverter = + formatters.get(formatters.size() - 1).getConverter(); + assertThat(lastConverter).isInstanceOf(ThrowablePatternConverter.class); + LogEventPatternConverter secondLastConverter = + formatters.get(formatters.size() - 2).getConverter(); + assertThat(secondLastConverter).isInstanceOf(LineSeparatorPatternConverter.class); + } else { + // Case 2: the original pattern does not end with a new line, so we add a composite converter + // that appends a new line and the exception if an exception is present. + assertThat(formatters).hasSizeGreaterThan(0); + final LogEventPatternConverter lastConverter = + formatters.get(formatters.size() - 1).getConverter(); + assertThat(lastConverter).isInstanceOf(VariablesNotEmptyReplacementConverter.class); + final List<PatternFormatter> nestedFormatters = + ((VariablesNotEmptyReplacementConverter) lastConverter).formatters; + assertThat(nestedFormatters).hasSize(2); + assertThat(nestedFormatters.get(0).getConverter()).isInstanceOf(LineSeparatorPatternConverter.class); + assertThat(nestedFormatters.get(1).getConverter()).isInstanceOf(ThrowablePatternConverter.class); + } + } + @Test void testExceptionWithFilters() { final List<PatternFormatter> formatters = diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/RootThrowablePatternConverterTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/RootThrowablePatternConverterTest.java index b7cee50b3a..80e4f008bf 100644 --- a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/RootThrowablePatternConverterTest.java +++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/RootThrowablePatternConverterTest.java @@ -91,11 +91,11 @@ class RootThrowablePatternConverterTest { @Test @Override - void output_should_be_newline_prefixed() { + void output_should_not_be_newline_prefixed() { final String pattern = "%p" + patternPrefix; final String stackTrace = convert(pattern); final String expectedStart = String.format( - "%s%n[CIRCULAR REFERENCE: %s", LEVEL, EXCEPTION.getClass().getCanonicalName()); + "%s[CIRCULAR REFERENCE: %s", LEVEL, EXCEPTION.getClass().getCanonicalName()); assertThat(stackTrace).as("pattern=`%s`", pattern).startsWith(expectedStart); } diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/ThrowablePatternConverterTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/ThrowablePatternConverterTest.java index e178526fd9..24230dd23b 100644 --- a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/ThrowablePatternConverterTest.java +++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/ThrowablePatternConverterTest.java @@ -37,6 +37,7 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; +import org.junitpioneer.jupiter.Issue; /** * {@link ThrowablePatternConverter} tests. @@ -384,11 +385,12 @@ public class ThrowablePatternConverterTest { } @Test - void output_should_be_newline_prefixed() { + @Issue("https://github.com/apache/logging-log4j2/issues/3873") + void output_should_not_be_newline_prefixed() { final String pattern = "%p" + patternPrefix; final String stackTrace = convert(pattern); final String expectedStart = - String.format("%s%n%s", LEVEL, EXCEPTION.getClass().getCanonicalName()); + String.format("%s%s", LEVEL, EXCEPTION.getClass().getCanonicalName()); assertThat(stackTrace).as("pattern=`%s`", pattern).startsWith(expectedStart); } diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/PatternParser.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/PatternParser.java index 767bbda01a..332ee535c4 100644 --- a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/PatternParser.java +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/PatternParser.java @@ -212,7 +212,26 @@ public final class PatternParser { list.add(new PatternFormatter(pc, field)); } if (alwaysWriteExceptions && !handlesThrowable) { - final LogEventPatternConverter pc = ThrowablePatternConverter.newInstance(config, new String[0]); + // We need to guarantee that an exception is always written, + // and that it is cleanly separated from the main log line by a newline. + final LogEventPatternConverter pc; + // Look at the last converter in the existing pattern. + final PatternFormatter lastFormatter = list.isEmpty() ? null : list.get(list.size() - 1); + + if (lastFormatter == null || !(lastFormatter.getConverter() instanceof LineSeparatorPatternConverter)) { + // Case 1: The pattern does NOT already end with a newline. + // In this case, we append a composite converter `%notEmpty{%n%ex}`. + // - If no exception is present, it renders nothing (so the pattern behaves exactly as before). + // - If an exception is present, it renders a newline followed by the stack trace. + pc = VariablesNotEmptyReplacementConverter.newInstance(config, new String[] {"%n%ex"}); + } else { + // Case 2: The pattern already ends with a newline. + // In this case, we only need to add `%ex`: + // - If no exception is present, nothing changes. + // - If an exception is present, it is written immediately after the newline already in the pattern. + pc = ThrowablePatternConverter.newInstance(config, Strings.EMPTY_ARRAY); + } + // Finally, add the chosen converter to the end of the pattern. list.add(new PatternFormatter(pc, FormattingInfo.getDefault())); } return list; diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowableStackTraceRenderer.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowableStackTraceRenderer.java index a6211147a8..b16e9b9836 100644 --- a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowableStackTraceRenderer.java +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowableStackTraceRenderer.java @@ -16,8 +16,6 @@ */ package org.apache.logging.log4j.core.pattern; -import static org.apache.logging.log4j.util.Strings.LINE_SEPARATOR; - import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -55,7 +53,6 @@ class ThrowableStackTraceRenderer<C extends ThrowableStackTraceRenderer.Context> if (maxLineCount > 0) { try { C context = createContext(throwable); - ensureNewlineSuffix(buffer); renderThrowable(buffer, throwable, context, new HashSet<>(), lineSeparator); } catch (final Exception error) { if (error != MAX_LINE_COUNT_EXCEEDED) { @@ -65,13 +62,6 @@ class ThrowableStackTraceRenderer<C extends ThrowableStackTraceRenderer.Context> } } - private static void ensureNewlineSuffix(final StringBuilder buffer) { - final int bufferLength = buffer.length(); - if (bufferLength > 0 && buffer.charAt(bufferLength - 1) != '\n') { - buffer.append(LINE_SEPARATOR); - } - } - @SuppressWarnings("unchecked") C createContext(final Throwable throwable) { final Map<Throwable, Context.Metadata> metadataByThrowable = Context.Metadata.ofThrowable(throwable); diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/VariablesNotEmptyReplacementConverter.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/VariablesNotEmptyReplacementConverter.java index e5d2ba9ecf..7fda5791d5 100644 --- a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/VariablesNotEmptyReplacementConverter.java +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/VariablesNotEmptyReplacementConverter.java @@ -35,7 +35,8 @@ import org.apache.logging.log4j.util.PerformanceSensitive; @PerformanceSensitive("allocation") public final class VariablesNotEmptyReplacementConverter extends LogEventPatternConverter { - private final List<PatternFormatter> formatters; + // package private for testing + final List<PatternFormatter> formatters; /** * Constructs the converter. diff --git a/src/changelog/.2.x.x/3873_throwable_converter_new_line.xml b/src/changelog/.2.x.x/3873_throwable_converter_new_line.xml new file mode 100644 index 0000000000..4db524d4cb --- /dev/null +++ b/src/changelog/.2.x.x/3873_throwable_converter_new_line.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="UTF-8"?> +<entry xmlns="https://logging.apache.org/xml/ns" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation=" + https://logging.apache.org/xml/ns + https://logging.apache.org/xml/ns/log4j-changelog-0.xsd" + type="fixed"> + <issue id="3873" link="https://github.com/apache/logging-log4j2/issues/3873"/> + <description format="asciidoc"> + Throwable converters no longer prepend newlines based on context; output follows the pattern exactly. + </description> +</entry>