This is an automated email from the ASF dual-hosted git repository.

pkarwasz pushed a commit to branch fix/2.x/handles-throwable
in repository https://gitbox.apache.org/repos/asf/logging-log4j2.git

commit 433fe04e4b8de7f190ace3d8b986bb5f0edd5346
Author: Piotr P. Karwasz <[email protected]>
AuthorDate: Sun Sep 7 23:13:35 2025 +0200

    fix: recognize nested converters in `alwaysWriteExceptions`
    
    `PatternParser` was not detecting when a throwable was already rendered 
inside a *nested* converter (e.g. `%style{%ex}{red}`).
    Because `handlesThrowable` was missing for those wrappers, the parser 
assumed no throwable was present and incorrectly auto-appended another `%ex`, 
causing the exception to be logged twice.
    
    This change implements `handlesThrowable` for the relevant nested 
converters so that `alwaysWriteExceptions` correctly recognizes them and no 
duplicate stack traces are produced.
---
 .../log4j/core/pattern/PatternParserTest.java      | 51 +++++++++++++++++++++-
 .../core/pattern/AbstractStyleNameConverter.java   |  7 +++
 .../core/pattern/EncodingPatternConverter.java     |  7 ++-
 .../pattern/EqualsBaseReplacementConverter.java    |  7 +++
 .../log4j/core/pattern/HighlightConverter.java     |  9 ++--
 .../core/pattern/LogEventPatternConverter.java     | 11 +++--
 .../log4j/core/pattern/MaxLengthConverter.java     |  7 +++
 .../core/pattern/RegexReplacementConverter.java    |  7 +++
 .../logging/log4j/core/pattern/StyleConverter.java |  9 ++--
 .../VariablesNotEmptyReplacementConverter.java     |  7 +++
 src/changelog/.2.x.x/3920-nested-throwables.xml    | 12 +++++
 11 files changed, 112 insertions(+), 22 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..53602d9ea2 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;
@@ -40,6 +41,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.ValueSource;
 
 class PatternParserTest {
 
@@ -89,6 +92,50 @@ class PatternParserTest {
         validateConverter(formatters, 1, "Line Sep");
     }
 
+    @ParameterizedTest
+    @ValueSource(strings = {"%m", "%p", "%c", "%d"})
+    void testAlwaysWriteExceptions_appendsThrowable(String pattern) {
+        // With alwaysWriteExceptions=true, parser should auto-append a 
%throwable
+        final List<PatternFormatter> formatters = parser.parse(pattern + "%n", 
true, false, false);
+        assertThat(formatters).hasSize(3);
+        
assertThat(formatters.get(2).getConverter()).isInstanceOf(ThrowablePatternConverter.class);
+
+        // With alwaysWriteExceptions=false, parser should leave the pattern 
unchanged
+        final List<PatternFormatter> formatters2 = parser.parse(pattern + 
"%n", false, false, false);
+        assertThat(formatters2).hasSize(2);
+        
assertThat(formatters2.get(1).getConverter()).isNotInstanceOf(ThrowablePatternConverter.class);
+    }
+
+    @ParameterizedTest
+    @ValueSource(
+            strings = {
+                "%black{%throwable}",
+                "%blue{%throwable}",
+                "%cyan{%throwable}",
+                "%green{%throwable}",
+                "%magenta{%throwable}",
+                "%red{%throwable}",
+                "%white{%throwable}",
+                "%yellow{%throwable}",
+                "%encode{%throwable}{JSON}",
+                "%equals{%throwable}{java.lang.Throwable}{T}",
+                "%equalsIgnoreCase{%throwable}{java.lang.Throwable}{T}",
+                "%highlight{%throwable}",
+                "%maxLen{%throwable}{1024}",
+                "%replace{%throwable}{\n}{ }",
+                "%style{%throwable}{red bold}",
+                "%notEmpty{%throwable}",
+            })
+    void testAlwaysWriteExceptions_recognizesNestedPatterns(String pattern) {
+        // With alwaysWriteExceptions=true, parser must detect the nested 
%throwable
+        // and NOT auto-append another one at the top level
+        final List<PatternFormatter> formatters = parser.parse(pattern, true, 
false, false);
+
+        // Only one top-level formatter is expected (the wrapper itself), not 
a trailing ThrowablePatternConverter
+        assertThat(formatters).hasSize(1);
+        
assertThat(formatters.get(0).getConverter()).isNotInstanceOf(ThrowablePatternConverter.class);
+    }
+
     /**
      * Test the custom pattern
      */
@@ -116,8 +163,8 @@ class PatternParserTest {
             formatter.format(event, buf);
         }
         final String str = buf.toString();
-        final String expected = "INFO  [PatternParserTest        :101 ] - 
Hello, world" + Strings.LINE_SEPARATOR;
-        assertTrue(str.endsWith(expected), "Expected to end with: " + expected 
+ ". Actual: " + str);
+        final String expected = "INFO  [PatternParserTest        :148 ] - 
Hello, world" + Strings.LINE_SEPARATOR;
+        assertThat(str).endsWith(expected);
     }
 
     @Test
diff --git 
a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/AbstractStyleNameConverter.java
 
b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/AbstractStyleNameConverter.java
index b7b5e35018..98d69acade 100644
--- 
a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/AbstractStyleNameConverter.java
+++ 
b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/AbstractStyleNameConverter.java
@@ -376,4 +376,11 @@ public abstract class AbstractStyleNameConverter extends 
LogEventPatternConverte
             toAppendTo.append(AnsiEscape.getDefaultStyle());
         }
     }
+
+    @Override
+    public boolean handlesThrowable() {
+        return formatters.stream()
+                .map(PatternFormatter::getConverter)
+                .anyMatch(LogEventPatternConverter::handlesThrowable);
+    }
 }
diff --git 
a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/EncodingPatternConverter.java
 
b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/EncodingPatternConverter.java
index 970517f69f..2458e2cb70 100644
--- 
a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/EncodingPatternConverter.java
+++ 
b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/EncodingPatternConverter.java
@@ -54,10 +54,9 @@ public final class EncodingPatternConverter extends 
LogEventPatternConverter {
 
     @Override
     public boolean handlesThrowable() {
-        return formatters != null
-                && formatters.stream()
-                        .map(PatternFormatter::getConverter)
-                        .anyMatch(LogEventPatternConverter::handlesThrowable);
+        return formatters.stream()
+                .map(PatternFormatter::getConverter)
+                .anyMatch(LogEventPatternConverter::handlesThrowable);
     }
 
     /**
diff --git 
a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/EqualsBaseReplacementConverter.java
 
b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/EqualsBaseReplacementConverter.java
index 904d498169..e71edb6147 100644
--- 
a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/EqualsBaseReplacementConverter.java
+++ 
b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/EqualsBaseReplacementConverter.java
@@ -99,4 +99,11 @@ public abstract class EqualsBaseReplacementConverter extends 
LogEventPatternConv
             substitutionBuffer.append(substitution);
         }
     }
+
+    @Override
+    public boolean handlesThrowable() {
+        return formatters.stream()
+                .map(PatternFormatter::getConverter)
+                .anyMatch(LogEventPatternConverter::handlesThrowable);
+    }
 }
diff --git 
a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/HighlightConverter.java
 
b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/HighlightConverter.java
index 29663b4f5a..6f72d2f47e 100644
--- 
a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/HighlightConverter.java
+++ 
b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/HighlightConverter.java
@@ -265,11 +265,8 @@ public final class HighlightConverter extends 
LogEventPatternConverter implement
 
     @Override
     public boolean handlesThrowable() {
-        for (final PatternFormatter formatter : patternFormatters) {
-            if (formatter.handlesThrowable()) {
-                return true;
-            }
-        }
-        return false;
+        return patternFormatters.stream()
+                .map(PatternFormatter::getConverter)
+                .anyMatch(LogEventPatternConverter::handlesThrowable);
     }
 }
diff --git 
a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/LogEventPatternConverter.java
 
b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/LogEventPatternConverter.java
index 4d4bc2cbc7..4c175abdb4 100644
--- 
a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/LogEventPatternConverter.java
+++ 
b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/LogEventPatternConverter.java
@@ -53,13 +53,16 @@ public abstract class LogEventPatternConverter extends 
AbstractPatternConverter
     }
 
     /**
-     * Normally pattern converters are not meant to handle Exceptions although 
few pattern converters might.
+     * Tests whether this pattern converter is renders a {@link Throwable}.
+     *
      * <p>
-     * By examining the return values for this method, the containing layout 
will determine whether it handles
-     * throwables or not.
+     * The {@link PatternParser} checks this flag when processing the
+     * {@code alwaysWriteExceptions} option: if no converter in the pattern 
handles
+     * throwables, the parser automatically appends a converter to ensure 
exceptions are still written.
      * </p>
      *
-     * @return true if this PatternConverter handles throwables
+     * @return {@code true} if this converter consumes and renders a {@link 
Throwable},
+     *         {@code false} otherwise
      */
     public boolean handlesThrowable() {
         return false;
diff --git 
a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/MaxLengthConverter.java
 
b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/MaxLengthConverter.java
index 1a2805c23b..432cd4f61f 100644
--- 
a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/MaxLengthConverter.java
+++ 
b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/MaxLengthConverter.java
@@ -99,4 +99,11 @@ public final class MaxLengthConverter extends 
LogEventPatternConverter {
             }
         }
     }
+
+    @Override
+    public boolean handlesThrowable() {
+        return formatters.stream()
+                .map(PatternFormatter::getConverter)
+                .anyMatch(LogEventPatternConverter::handlesThrowable);
+    }
 }
diff --git 
a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/RegexReplacementConverter.java
 
b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/RegexReplacementConverter.java
index 4a21a753bb..27bd86ada7 100644
--- 
a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/RegexReplacementConverter.java
+++ 
b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/RegexReplacementConverter.java
@@ -92,4 +92,11 @@ public final class RegexReplacementConverter extends 
LogEventPatternConverter {
         }
         
toAppendTo.append(pattern.matcher(buf.toString()).replaceAll(substitution));
     }
+
+    @Override
+    public boolean handlesThrowable() {
+        return formatters.stream()
+                .map(PatternFormatter::getConverter)
+                .anyMatch(LogEventPatternConverter::handlesThrowable);
+    }
 }
diff --git 
a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/StyleConverter.java
 
b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/StyleConverter.java
index 40267f6155..d6c0d123d8 100644
--- 
a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/StyleConverter.java
+++ 
b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/StyleConverter.java
@@ -129,12 +129,9 @@ public final class StyleConverter extends 
LogEventPatternConverter implements An
 
     @Override
     public boolean handlesThrowable() {
-        for (final PatternFormatter formatter : patternFormatters) {
-            if (formatter.handlesThrowable()) {
-                return true;
-            }
-        }
-        return false;
+        return patternFormatters.stream()
+                .map(PatternFormatter::getConverter)
+                .anyMatch(LogEventPatternConverter::handlesThrowable);
     }
 
     /**
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..9929dc5a2d 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
@@ -118,4 +118,11 @@ public final class VariablesNotEmptyReplacementConverter 
extends LogEventPattern
         }
         return true;
     }
+
+    @Override
+    public boolean handlesThrowable() {
+        return formatters.stream()
+                .map(PatternFormatter::getConverter)
+                .anyMatch(LogEventPatternConverter::handlesThrowable);
+    }
 }
diff --git a/src/changelog/.2.x.x/3920-nested-throwables.xml 
b/src/changelog/.2.x.x/3920-nested-throwables.xml
new file mode 100644
index 0000000000..a8c9b13cfe
--- /dev/null
+++ b/src/changelog/.2.x.x/3920-nested-throwables.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="3920" link="https://github.com/apache/logging-log4j2/pull/3920"/>
+  <description format="asciidoc">
+    Fix detection of throwable converters inside nested patterns when applying 
`alwaysWriteExceptions`.
+  </description>
+</entry>

Reply via email to