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>

Reply via email to