You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@logging.apache.org by vy...@apache.org on 2024/10/09 14:44:36 UTC

(logging-log4j2) branch feature/main/ex-newline created (now 2a398ead98)

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

vy pushed a change to branch feature/main/ex-newline
in repository https://gitbox.apache.org/repos/asf/logging-log4j2.git


      at 2a398ead98 Prefix stack traces with a newline in Pattern Layout (#3045)

This branch includes the following new commits:

     new 2a398ead98 Prefix stack traces with a newline in Pattern Layout (#3045)

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(logging-log4j2) 01/01: Prefix stack traces with a newline in Pattern Layout (#3045)

Posted by vy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

vy pushed a commit to branch feature/main/ex-newline
in repository https://gitbox.apache.org/repos/asf/logging-log4j2.git

commit 2a398ead98e2d46b2616e5e523bc5b2b10186cb6
Author: Volkan Yazıcı <vo...@yazi.ci>
AuthorDate: Wed Oct 9 15:49:33 2024 +0200

    Prefix stack traces with a newline in Pattern Layout (#3045)
    
    - Exception converters are reworked to ensure a newline
      prefix (which used to be a whitespace)
    
    - Fix property extraction for root exceptions
      (e.g., %rEx{short.className})
    
    Co-authored-by: AlanYu <al...@gmail.com>
    Co-authored-by: Piotr P. Karwasz <pi...@github.copernik.eu>
---
 .../AsyncLoggerTestArgumentFreedOnErrorTest.java   |  64 +++---
 .../async/logger/QueueFullAsyncLogger3Test.java    |  82 ++------
 .../log4j/core/GarbageCollectionHelper.java        |  68 ------
 .../logging/log4j/core/test/internal/GcHelper.java |  54 +++++
 .../core/test/internal/GcPressureGenerator.java    |  81 ++++++++
 .../src/test/java/foo/TestFriendlyException.java   |  27 ++-
 .../log4j/core/EventParameterMemoryLeakTest.java   | 138 ++++++++-----
 ...ReusableParameterizedMessageMemoryLeakTest.java |  34 ++-
 .../rolling/RollingAppenderDirectWriteTest.java    |   5 +-
 .../NestedLoggingFromThrowableMessageTest.java     |   7 +-
 .../logging/log4j/core/internal/GcHelperTest.java  |  29 +++
 .../PatternLayoutDefaultExceptionHandlerTest.java  |  75 +++++++
 .../ExtendedThrowablePatternConverterTest.java     |   9 +-
 .../pattern/RootThrowablePatternConverterTest.java |  27 ++-
 .../pattern/ThrowablePatternConverterTest.java     |  71 ++++---
 .../logging/log4j/core/pattern/ThrowableTest.java  | 229 ---------------------
 .../logging/log4j/core/util/ThrowablesTest.java    |  19 +-
 .../resources/EventParameterMemoryLeakTest.xml     |  35 ----
 .../pattern/ExtendedThrowablePatternConverter.java |   8 +-
 .../pattern/RootThrowablePatternConverter.java     |  13 +-
 ...ThrowableExtendedStackTraceRendererFactory.java |  31 +++
 .../ThrowableInvertedPropertyRendererFactory.java  |  40 ++++
 .../ThrowableInvertedStackTraceRenderer.java       |   2 +-
 ...ThrowableInvertedStackTraceRendererFactory.java |  31 +++
 .../core/pattern/ThrowablePatternConverter.java    |  55 ++---
 .../core/pattern/ThrowablePropertyRenderer.java    |  98 ---------
 .../pattern/ThrowablePropertyRendererFactory.java  | 120 +++++++++++
 .../core/pattern/ThrowableStackTraceRenderer.java  |  10 +
 .../ThrowableStackTraceRendererFactory.java        |  33 +++
 .../apache/logging/log4j/core/util/Throwables.java |  37 ++--
 .../json/resolver/StackTraceStringResolver.java    |   6 +-
 .../modules/ROOT/pages/manual/pattern-layout.adoc  | 150 ++++++++------
 32 files changed, 899 insertions(+), 789 deletions(-)

diff --git a/log4j-async-logger/src/test/java/org/apache/logging/log4j/async/logger/AsyncLoggerTestArgumentFreedOnErrorTest.java b/log4j-async-logger/src/test/java/org/apache/logging/log4j/async/logger/AsyncLoggerTestArgumentFreedOnErrorTest.java
index 68c98d42ba..b3ae9d5999 100644
--- a/log4j-async-logger/src/test/java/org/apache/logging/log4j/async/logger/AsyncLoggerTestArgumentFreedOnErrorTest.java
+++ b/log4j-async-logger/src/test/java/org/apache/logging/log4j/async/logger/AsyncLoggerTestArgumentFreedOnErrorTest.java
@@ -16,63 +16,55 @@
  */
 package org.apache.logging.log4j.async.logger;
 
-import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.apache.logging.log4j.core.test.internal.GcHelper.awaitGarbageCollection;
 
-import java.util.concurrent.CountDownLatch;
-import java.util.concurrent.TimeUnit;
-import org.apache.logging.log4j.LogManager;
-import org.apache.logging.log4j.core.GarbageCollectionHelper;
+import org.apache.logging.log4j.core.Logger;
+import org.apache.logging.log4j.core.LoggerContext;
 import org.apache.logging.log4j.core.test.TestConstants;
-import org.apache.logging.log4j.core.test.junit.ContextSelectorType;
 import org.apache.logging.log4j.message.Message;
+import org.apache.logging.log4j.plugins.di.DI;
 import org.apache.logging.log4j.test.junit.SetTestProperty;
+import org.apache.logging.log4j.test.junit.UsingStatusListener;
 import org.apache.logging.log4j.util.StringBuilderFormattable;
 import org.junit.jupiter.api.Tag;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInfo;
 
 @Tag("async")
-@SetTestProperty(key = TestConstants.GC_ENABLE_DIRECT_ENCODERS, value = "true")
-@SetTestProperty(key = TestConstants.ASYNC_FORMAT_MESSAGES_IN_BACKGROUND, value = "true")
-@ContextSelectorType(AsyncLoggerContextSelector.class)
-public class AsyncLoggerTestArgumentFreedOnErrorTest {
+class AsyncLoggerArgumentFreedOnErrorTest {
 
-    // LOG4J2-2725: events are cleared even after failure
+    /**
+     * Tests events are cleared even after failure.
+     *
+     * @see <a href="https://issues.apache.org/jira/browse/LOG4J2-2725">LOG4J2-2725</a>
+     */
     @Test
-    public void testMessageIsGarbageCollected() throws Exception {
-        final AsyncLogger log = (AsyncLogger) LogManager.getLogger("com.foo.Bar");
-        final CountDownLatch garbageCollectionLatch = new CountDownLatch(1);
-        log.fatal(new ThrowingMessage(garbageCollectionLatch));
-        try (final GarbageCollectionHelper gcHelper = new GarbageCollectionHelper()) {
-            gcHelper.run();
-            assertTrue(
-                    garbageCollectionLatch.await(30, TimeUnit.SECONDS), "Parameter should have been garbage collected");
-        }
+    @UsingStatusListener // Suppresses `StatusLogger` output, unless there is a failure
+    @SetTestProperty(key = TestConstants.GC_ENABLE_DIRECT_ENCODERS, value = "true")
+    @SetTestProperty(key = TestConstants.ASYNC_FORMAT_MESSAGES_IN_BACKGROUND, value = "true")
+    void parameters_throwing_exception_should_be_garbage_collected(final TestInfo testInfo) throws Exception {
+        awaitGarbageCollection(() -> {
+            final String loggerContextName = String.format("%s-LC", testInfo.getDisplayName());
+            try (final LoggerContext loggerContext =
+                    new AsyncLoggerContext(loggerContextName, null, null, DI.createInitializedFactory())) {
+                loggerContext.start();
+                final Logger logger = loggerContext.getRootLogger();
+                final ThrowingMessage parameter = new ThrowingMessage();
+                logger.fatal(parameter);
+                return parameter;
+            }
+        });
     }
 
     private static class ThrowingMessage implements Message, StringBuilderFormattable {
 
-        private final CountDownLatch latch;
-
-        ThrowingMessage(final CountDownLatch latch) {
-            this.latch = latch;
-        }
-
-        @Override
-        protected void finalize() throws Throwable {
-            latch.countDown();
-            super.finalize();
-        }
+        private ThrowingMessage() {}
 
         @Override
         public String getFormattedMessage() {
             throw new Error("Expected");
         }
 
-        @Override
-        public String getFormat() {
-            return "";
-        }
-
         @Override
         public Object[] getParameters() {
             return new Object[0];
diff --git a/log4j-async-logger/src/test/java/org/apache/logging/log4j/async/logger/QueueFullAsyncLogger3Test.java b/log4j-async-logger/src/test/java/org/apache/logging/log4j/async/logger/QueueFullAsyncLogger3Test.java
index 3641aa29b5..c0a23c4900 100644
--- a/log4j-async-logger/src/test/java/org/apache/logging/log4j/async/logger/QueueFullAsyncLogger3Test.java
+++ b/log4j-async-logger/src/test/java/org/apache/logging/log4j/async/logger/QueueFullAsyncLogger3Test.java
@@ -16,29 +16,32 @@
  */
 package org.apache.logging.log4j.async.logger;
 
-import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.apache.logging.log4j.async.logger.QueueFullAsyncAbstractTest.assertAsyncLogger;
 import static org.apache.logging.log4j.core.test.TestConstants.ASYNC_FORMAT_MESSAGES_IN_BACKGROUND;
 import static org.apache.logging.log4j.core.test.TestConstants.ASYNC_LOGGER_RING_BUFFER_SIZE;
 import static org.apache.logging.log4j.core.test.TestConstants.ASYNC_QUEUE_FULL_POLICY_CLASS_NAME;
+import static org.apache.logging.log4j.core.test.internal.GcHelper.awaitGarbageCollection;
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.junit.Assert.assertTrue;
 
+import java.util.List;
 import java.util.concurrent.CountDownLatch;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
 import org.apache.logging.log4j.Logger;
-import org.apache.logging.log4j.core.GarbageCollectionHelper;
 import org.apache.logging.log4j.core.LoggerContext;
 import org.apache.logging.log4j.core.async.AsyncQueueFullPolicyFactory;
 import org.apache.logging.log4j.core.async.DiscardingAsyncQueueFullPolicy;
 import org.apache.logging.log4j.core.impl.CoreProperties;
 import org.apache.logging.log4j.core.test.async.BlockingAppender;
+import org.apache.logging.log4j.core.test.async.QueueFullAbstractTest;
 import org.apache.logging.log4j.core.test.junit.ContextSelectorType;
 import org.apache.logging.log4j.core.test.junit.LoggerContextSource;
 import org.apache.logging.log4j.core.test.junit.Named;
 import org.apache.logging.log4j.message.Message;
+import org.apache.logging.log4j.message.SimpleMessage;
 import org.apache.logging.log4j.status.StatusLogger;
 import org.apache.logging.log4j.test.junit.SetTestProperty;
 import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.Timeout;
 
 /**
  * Tests queue full scenarios with pure AsyncLoggers (all loggers async).
@@ -47,7 +50,7 @@ import org.junit.jupiter.api.Timeout;
 @SetTestProperty(key = ASYNC_LOGGER_RING_BUFFER_SIZE, value = "128")
 @SetTestProperty(key = ASYNC_FORMAT_MESSAGES_IN_BACKGROUND, value = "true")
 @SetTestProperty(key = ASYNC_QUEUE_FULL_POLICY_CLASS_NAME, value = "Discard")
-public class QueueFullAsyncLogger3Test extends QueueFullAsyncAbstractTest {
+class QueueFullAsyncLogger3Test extends QueueFullAbstractTest {
 
     @Override
     protected void checkConfig(final LoggerContext ctx) {
@@ -60,64 +63,21 @@ public class QueueFullAsyncLogger3Test extends QueueFullAsyncAbstractTest {
     }
 
     @Test
-    @Timeout(value = 15, unit = SECONDS)
     @LoggerContextSource
-    public void discardedMessagesShouldBeGarbageCollected(
+    void discarded_messages_should_be_garbage_collected(
             final LoggerContext ctx, final @Named(APPENDER_NAME) BlockingAppender blockingAppender)
             throws InterruptedException {
-        checkConfig(ctx);
-        final Logger logger = ctx.getLogger(getClass());
-
-        blockingAppender.logEvents = null;
-        blockingAppender.countDownLatch = new CountDownLatch(1);
-        final int count = 200;
-        final CountDownLatch garbageCollectionLatch = new CountDownLatch(count);
-        for (int i = 0; i < count; i++) {
-            logger.info(new CountdownOnGarbageCollectMessage(garbageCollectionLatch));
-        }
-        blockingAppender.countDownLatch.countDown();
-
-        final GarbageCollectionHelper gcHelper = new GarbageCollectionHelper();
-        gcHelper.run();
-        try {
-            assertTrue("Parameter should have been garbage collected", garbageCollectionLatch.await(30, SECONDS));
-        } finally {
-            gcHelper.close();
-        }
-    }
-
-    private static final class CountdownOnGarbageCollectMessage implements Message {
-
-        private final CountDownLatch latch;
-
-        CountdownOnGarbageCollectMessage(final CountDownLatch latch) {
-            this.latch = latch;
-        }
-
-        @Override
-        public String getFormattedMessage() {
-            return "formatted";
-        }
-
-        @Override
-        public String getFormat() {
-            return null;
-        }
-
-        @Override
-        public Object[] getParameters() {
-            return org.apache.logging.log4j.util.Constants.EMPTY_OBJECT_ARRAY;
-        }
-
-        @Override
-        public Throwable getThrowable() {
-            return null;
-        }
-
-        @Override
-        protected void finalize() throws Throwable {
-            latch.countDown();
-            super.finalize();
-        }
+        awaitGarbageCollection(() -> {
+            checkConfig(ctx);
+            final Logger logger = ctx.getLogger(getClass());
+            blockingAppender.logEvents = null;
+            blockingAppender.countDownLatch = new CountDownLatch(1);
+            final List<Message> messages = IntStream.range(0, 200)
+                    .mapToObj(messageIndex -> new SimpleMessage("message " + messageIndex))
+                    .collect(Collectors.toList());
+            messages.forEach(logger::info);
+            blockingAppender.countDownLatch.countDown();
+            return messages;
+        });
     }
 }
diff --git a/log4j-core-test/src/main/java/org/apache/logging/log4j/core/GarbageCollectionHelper.java b/log4j-core-test/src/main/java/org/apache/logging/log4j/core/GarbageCollectionHelper.java
deleted file mode 100644
index 1feff3edd8..0000000000
--- a/log4j-core-test/src/main/java/org/apache/logging/log4j/core/GarbageCollectionHelper.java
+++ /dev/null
@@ -1,68 +0,0 @@
-/*
- * 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.logging.log4j.core;
-
-import static org.junit.Assert.assertTrue;
-
-import java.io.Closeable;
-import java.io.IOException;
-import java.io.OutputStream;
-import java.util.concurrent.CountDownLatch;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicBoolean;
-
-public final class GarbageCollectionHelper implements Closeable, Runnable {
-    private static final OutputStream sink = OutputStream.nullOutputStream();
-    private final AtomicBoolean running = new AtomicBoolean();
-    private final CountDownLatch latch = new CountDownLatch(1);
-    private final Thread gcThread = new Thread(new Runnable() {
-        @Override
-        public void run() {
-            try {
-                while (running.get()) {
-                    // Allocate data to help suggest a GC
-                    try {
-                        // 1mb of heap
-                        sink.write(new byte[1024 * 1024]);
-                    } catch (IOException ignored) {
-                    }
-                    // May no-op depending on the jvm configuration
-                    System.gc();
-                }
-            } finally {
-                latch.countDown();
-            }
-        }
-    });
-
-    @Override
-    public void run() {
-        if (running.compareAndSet(false, true)) {
-            gcThread.start();
-        }
-    }
-
-    @Override
-    public void close() {
-        running.set(false);
-        try {
-            assertTrue("GarbageCollectionHelper did not shut down cleanly", latch.await(10, TimeUnit.SECONDS));
-        } catch (InterruptedException e) {
-            throw new RuntimeException(e);
-        }
-    }
-}
diff --git a/log4j-core-test/src/main/java/org/apache/logging/log4j/core/test/internal/GcHelper.java b/log4j-core-test/src/main/java/org/apache/logging/log4j/core/test/internal/GcHelper.java
new file mode 100644
index 0000000000..7c32eaff26
--- /dev/null
+++ b/log4j-core-test/src/main/java/org/apache/logging/log4j/core/test/internal/GcHelper.java
@@ -0,0 +1,54 @@
+/*
+ * 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.logging.log4j.core.test.internal;
+
+import java.lang.ref.PhantomReference;
+import java.lang.ref.Reference;
+import java.lang.ref.ReferenceQueue;
+import org.junit.jupiter.api.function.ThrowingSupplier;
+
+public final class GcHelper {
+
+    private GcHelper() {}
+
+    /**
+     * Waits for the value to be garbage collected.
+     *
+     * @param valueSupplier a value provider
+     */
+    @SuppressWarnings({"unused", "UnusedAssignment"})
+    public static void awaitGarbageCollection(final ThrowingSupplier<?> valueSupplier) throws InterruptedException {
+
+        // Create the reference queue
+        final ReferenceQueue<Object> refQueue = new ReferenceQueue<>();
+        final Reference<?> ref;
+        try {
+            final Object value = valueSupplier.get();
+            ref = new PhantomReference<>(value, refQueue);
+        } catch (final Throwable error) {
+            throw new RuntimeException("failed obtaining value", error);
+        }
+
+        // Wait for the garbage collection
+        try (final GcPressureGenerator ignored = GcPressureGenerator.ofStarted()) {
+            final Reference<?> removedRef = refQueue.remove(30_000L);
+            if (removedRef == null) {
+                throw new AssertionError("garbage collector did not reclaim the value");
+            }
+        }
+    }
+}
diff --git a/log4j-core-test/src/main/java/org/apache/logging/log4j/core/test/internal/GcPressureGenerator.java b/log4j-core-test/src/main/java/org/apache/logging/log4j/core/test/internal/GcPressureGenerator.java
new file mode 100644
index 0000000000..b5be23b6b5
--- /dev/null
+++ b/log4j-core-test/src/main/java/org/apache/logging/log4j/core/test/internal/GcPressureGenerator.java
@@ -0,0 +1,81 @@
+/*
+ * 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.logging.log4j.core.test.internal;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Creates GC pressure by continuously allocating and {@link System#gc() triggering GC}.
+ */
+final class GcPressureGenerator implements AutoCloseable {
+
+    private final AtomicInteger sink = new AtomicInteger(0);
+
+    private final AtomicBoolean running = new AtomicBoolean(true);
+
+    private final CountDownLatch stopLatch = new CountDownLatch(1);
+
+    private GcPressureGenerator() {
+        startGeneratorThread();
+    }
+
+    private void startGeneratorThread() {
+        final String threadName = GcPressureGenerator.class.getSimpleName();
+        final Thread thread = new Thread(this::generateGarbage, threadName);
+        thread.setDaemon(true); // Avoid blocking JVM exit
+        thread.start();
+    }
+
+    private void generateGarbage() {
+        try {
+            while (running.get()) {
+                final Object object = new byte[1024 * 1024];
+                int positiveValue = Math.abs(object.hashCode());
+                sink.set(positiveValue);
+                System.gc();
+                System.runFinalization();
+            }
+        } finally {
+            stopLatch.countDown();
+        }
+    }
+
+    static GcPressureGenerator ofStarted() {
+        return new GcPressureGenerator();
+    }
+
+    @Override
+    public void close() {
+        final boolean signalled = running.compareAndSet(true, false);
+        if (signalled) {
+            try {
+                final boolean stopped = stopLatch.await(10, TimeUnit.SECONDS);
+                assertThat(stopped).isTrue();
+                assertThat(sink.get()).isPositive();
+            } catch (final InterruptedException error) {
+                // Restore the `interrupted` flag
+                Thread.currentThread().interrupt();
+                throw new RuntimeException(error);
+            }
+        }
+    }
+}
diff --git a/log4j-core-test/src/test/java/foo/TestFriendlyException.java b/log4j-core-test/src/test/java/foo/TestFriendlyException.java
index 4ce0c7d5a0..7c791dc20d 100644
--- a/log4j-core-test/src/test/java/foo/TestFriendlyException.java
+++ b/log4j-core-test/src/test/java/foo/TestFriendlyException.java
@@ -27,18 +27,21 @@ import org.apache.logging.log4j.util.Constants;
  * A testing friendly exception featuring
  * <ul>
  * <li>Distinct localized message</li>
- * <li>Non-Log4j package origin<sup>1</sup></li>
+ * <li>Non-Log4j<sup>1</sup> and fixed<sup>2</sup> (to {@code bar}) package origin</li>
  * <li>Sufficient causal chain depth</li>
- * <li>Cyclic causal chain</li>
+ * <li>Circular causal chain</li>
  * <li>Suppressed exceptions</li>
  * <li>Clutter-free stack trace (i.e., elements from JUnit, JDK, etc.)</li>
- * <li>Stack trace elements from named modules<sup>2</sup></li>
+ * <li>Stack trace elements from named modules<sup>3</sup></li>
  * </ul>
  * <p>
  * <sup>1</sup> Helps with observing stack trace manipulation effects of Log4j.
  * </p>
  * <p>
- * <sup>2</sup> Helps with testing module name serialization.
+ * <sup>2</sup> Helps to make the origin of {@link #INSTANCE} independent of the first test accessing to it.
+ * </p>
+ * <p>
+ * <sup>3</sup> Helps with testing module name serialization.
  * </p>
  */
 public final class TestFriendlyException extends RuntimeException {
@@ -48,6 +51,9 @@ public final class TestFriendlyException extends RuntimeException {
         assertThat(TestFriendlyException.class.getPackage().getName()).doesNotStartWith("org.apache");
     }
 
+    public static final StackTraceElement ORG_APACHE_REPLACEMENT_STACK_TRACE_ELEMENT =
+            new StackTraceElement("bar.OrgApacheReplacement", "someMethod", "OrgApacheReplacement.java", 0);
+
     public static final StackTraceElement NAMED_MODULE_STACK_TRACE_ELEMENT = namedModuleStackTraceElement();
 
     @SuppressWarnings("resource")
@@ -127,9 +133,13 @@ public final class TestFriendlyException extends RuntimeException {
 
     private static Stream<StackTraceElement> filterStackTraceElement(
             final StackTraceElement stackTraceElement, final boolean[] seenExcludedStackTraceElement) {
+
+        // Short-circuit if we have already encountered an excluded stack trace element
         if (seenExcludedStackTraceElement[0]) {
             return Stream.empty();
         }
+
+        // Check if the class name is excluded
         final String className = stackTraceElement.getClassName();
         for (final String excludedClassNamePrefix : EXCLUDED_CLASS_NAME_PREFIXES) {
             if (className.startsWith(excludedClassNamePrefix)) {
@@ -137,6 +147,15 @@ public final class TestFriendlyException extends RuntimeException {
                 return Stream.empty();
             }
         }
+
+        // Replace `org.apache`-originating entries with a constant one.
+        // Without this, `INSTANCE` might yield different origin depending on the first class accessing to it.
+        // We remove this ambiguity and fix our origin to a constant instead.
+        if (className.startsWith("org.apache")) {
+            return Stream.of(ORG_APACHE_REPLACEMENT_STACK_TRACE_ELEMENT);
+        }
+
+        // Otherwise, it looks good
         return Stream.of(stackTraceElement);
     }
 
diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/EventParameterMemoryLeakTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/EventParameterMemoryLeakTest.java
index a036484f4c..009058e772 100644
--- a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/EventParameterMemoryLeakTest.java
+++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/EventParameterMemoryLeakTest.java
@@ -16,76 +16,99 @@
  */
 package org.apache.logging.log4j.core;
 
-import static org.hamcrest.MatcherAssert.assertThat;
-import static org.hamcrest.Matchers.containsString;
-import static org.hamcrest.Matchers.is;
-import static org.junit.jupiter.api.Assertions.assertNull;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-import java.io.BufferedReader;
-import java.io.File;
-import java.io.FileReader;
-import java.lang.ref.Cleaner;
-import java.util.concurrent.CountDownLatch;
-import java.util.concurrent.TimeUnit;
-import org.apache.logging.log4j.LogManager;
+import static org.apache.logging.log4j.core.test.internal.GcHelper.awaitGarbageCollection;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.List;
+import org.apache.logging.log4j.Level;
 import org.apache.logging.log4j.Logger;
-import org.apache.logging.log4j.core.test.CoreLoggerContexts;
+import org.apache.logging.log4j.core.config.Configuration;
+import org.apache.logging.log4j.core.config.builder.api.AppenderComponentBuilder;
+import org.apache.logging.log4j.core.config.builder.api.ConfigurationBuilder;
+import org.apache.logging.log4j.core.config.builder.api.ConfigurationBuilderFactory;
+import org.apache.logging.log4j.core.config.builder.api.LayoutComponentBuilder;
+import org.apache.logging.log4j.core.config.builder.api.RootLoggerComponentBuilder;
+import org.apache.logging.log4j.core.config.builder.impl.BuiltConfiguration;
 import org.apache.logging.log4j.core.test.TestConstants;
+import org.apache.logging.log4j.core.test.appender.ListAppender;
+import org.apache.logging.log4j.plugins.di.DI;
 import org.apache.logging.log4j.test.junit.SetTestProperty;
 import org.junit.jupiter.api.Tag;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInfo;
 
 @Tag("functional")
-@SetTestProperty(key = TestConstants.GC_ENABLE_DIRECT_ENCODERS, value = "true")
-@SetTestProperty(key = TestConstants.CONFIGURATION_FILE, value = "EventParameterMemoryLeakTest.xml")
-public class EventParameterMemoryLeakTest {
+class EventParameterMemoryLeakTest {
 
     @Test
-    @SuppressWarnings("UnusedAssignment") // parameter set to null to allow garbage collection
-    public void testParametersAreNotLeaked() throws Exception {
-        final File file = new File("target", "EventParameterMemoryLeakTest.log");
-        assertTrue(!file.exists() || file.delete(), "Deleted old file before test");
-
-        final Logger log = LogManager.getLogger("com.foo.Bar");
-        final CountDownLatch latch = new CountDownLatch(1);
-        final Cleaner cleaner = Cleaner.create();
-        Object parameter = new ParameterObject("paramValue");
-        cleaner.register(parameter, latch::countDown);
-        log.info("Message with parameter {}", parameter);
-        log.info(parameter);
-        log.info("test", new ObjectThrowable(parameter));
-        log.info("test {}", "hello", new ObjectThrowable(parameter));
-        parameter = null;
-        CoreLoggerContexts.stopLoggerContext(file);
-        final BufferedReader reader = new BufferedReader(new FileReader(file));
-        final String line1 = reader.readLine();
-        final String line2 = reader.readLine();
-        final String line3 = reader.readLine();
-        // line4 is empty line because of the line separator after throwable pattern
-        final String line4 = reader.readLine();
-        final String line5 = reader.readLine();
-        final String line6 = reader.readLine();
-        final String line7 = reader.readLine();
-        reader.close();
-        file.delete();
-        assertThat(line1, containsString("Message with parameter paramValue"));
-        assertThat(line2, containsString("paramValue"));
-        assertThat(line3, containsString("paramValue"));
-        assertThat(line4, is(""));
-        assertThat(line5, containsString("paramValue"));
-        assertThat(line6, is(""));
-        assertNull(line7, "Expected only six lines");
-        try (final GarbageCollectionHelper gcHelper = new GarbageCollectionHelper()) {
-            gcHelper.run();
-            assertTrue(latch.await(30, TimeUnit.SECONDS), "Parameter should have been garbage collected");
-        }
+    @SetTestProperty(key = TestConstants.GC_ENABLE_DIRECT_ENCODERS, value = "true")
+    void parameters_should_be_garbage_collected(final TestInfo testInfo) throws Throwable {
+        awaitGarbageCollection(() -> {
+            final ListAppender[] appenderRef = {null};
+            final Logger[] loggerRef = {null};
+            try (final LoggerContext ignored = createLoggerContext(testInfo, appenderRef, loggerRef)) {
+
+                // Log messages
+                final ParameterObject parameter = new ParameterObject("paramValue");
+                loggerRef[0].info("Message with parameter {}", parameter);
+                loggerRef[0].info(parameter);
+                loggerRef[0].info("test", new ObjectThrowable(parameter));
+                loggerRef[0].info("test {}", "hello", new ObjectThrowable(parameter));
+
+                // Verify the logging
+                final List<String> messages = appenderRef[0].getMessages();
+                assertThat(messages).hasSize(4);
+                assertThat(messages.get(0)).isEqualTo("Message with parameter %s", parameter.value);
+                assertThat(messages.get(1)).isEqualTo(parameter.value);
+                assertThat(messages.get(2))
+                        .startsWith(String.format("test%n%s: %s", ObjectThrowable.class.getName(), parameter.value));
+                assertThat(messages.get(3))
+                        .startsWith(
+                                String.format("test hello%n%s: %s", ObjectThrowable.class.getName(), parameter.value));
+
+                // Return the GC subject
+                return parameter;
+            }
+        });
+    }
+
+    private static LoggerContext createLoggerContext(
+            final TestInfo testInfo, final ListAppender[] appenderRef, final Logger[] loggerRef) {
+        final String loggerContextName = String.format("%s-LC", testInfo.getDisplayName());
+        final LoggerContext loggerContext =
+                new LoggerContext(loggerContextName, null, (String) null, DI.createInitializedFactory());
+        final String appenderName = "LIST";
+        final Configuration configuration = createConfiguration(appenderName);
+        loggerContext.start(configuration);
+        appenderRef[0] = configuration.getAppender(appenderName);
+        assertThat(appenderRef[0]).isNotNull();
+        final Class<?> testClass = testInfo.getTestClass().orElse(null);
+        assertThat(testClass).isNotNull();
+        loggerRef[0] = loggerContext.getLogger(testClass);
+        return loggerContext;
+    }
+
+    @SuppressWarnings("SameParameterValue")
+    private static Configuration createConfiguration(final String appenderName) {
+        final ConfigurationBuilder<BuiltConfiguration> configBuilder =
+                ConfigurationBuilderFactory.newConfigurationBuilder();
+        final LayoutComponentBuilder layoutComponentBuilder =
+                configBuilder.newLayout("PatternLayout").addAttribute("pattern", "%m");
+        final AppenderComponentBuilder appenderComponentBuilder =
+                configBuilder.newAppender(appenderName, "List").add(layoutComponentBuilder);
+        final RootLoggerComponentBuilder loggerComponentBuilder =
+                configBuilder.newRootLogger(Level.ALL).add(configBuilder.newAppenderRef(appenderName));
+        return configBuilder
+                .add(appenderComponentBuilder)
+                .add(loggerComponentBuilder)
+                .build(false);
     }
 
     private static final class ParameterObject {
+
         private final String value;
 
-        ParameterObject(final String value) {
+        private ParameterObject(final String value) {
             this.value = value;
         }
 
@@ -96,9 +119,10 @@ public class EventParameterMemoryLeakTest {
     }
 
     private static final class ObjectThrowable extends RuntimeException {
+
         private final Object object;
 
-        ObjectThrowable(final Object object) {
+        private ObjectThrowable(final Object object) {
             super(String.valueOf(object));
             this.object = object;
         }
diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/ReusableParameterizedMessageMemoryLeakTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/ReusableParameterizedMessageMemoryLeakTest.java
index 00f8ba2da3..1a425b5eac 100644
--- a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/ReusableParameterizedMessageMemoryLeakTest.java
+++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/ReusableParameterizedMessageMemoryLeakTest.java
@@ -16,10 +16,8 @@
  */
 package org.apache.logging.log4j.core;
 
-import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.apache.logging.log4j.core.test.internal.GcHelper.awaitGarbageCollection;
 
-import java.util.concurrent.CountDownLatch;
-import java.util.concurrent.TimeUnit;
 import org.apache.logging.log4j.message.ReusableMessage;
 import org.apache.logging.log4j.message.ReusableMessageFactory;
 import org.junit.jupiter.api.Test;
@@ -27,36 +25,28 @@ import org.junit.jupiter.api.Test;
 public class ReusableParameterizedMessageMemoryLeakTest {
 
     @Test
-    public void testParametersAreNotLeaked() throws Exception {
-        final CountDownLatch latch = new CountDownLatch(1);
-        final ReusableMessage message = (ReusableMessage)
-                ReusableMessageFactory.INSTANCE.newMessage("foo {}", new ParameterObject("paramValue", latch));
-        // Large enough for the parameters, but smaller than the default reusable array size.
-        message.swapParameters(new Object[5]);
-        try (final GarbageCollectionHelper gcHelper = new GarbageCollectionHelper()) {
-            gcHelper.run();
-            assertTrue(latch.await(30, TimeUnit.SECONDS), "Parameter should have been garbage collected");
-        }
+    public void parameters_should_be_garbage_collected() throws Exception {
+        awaitGarbageCollection(() -> {
+            final ParameterObject parameter = new ParameterObject("paramValue");
+            final ReusableMessage message =
+                    (ReusableMessage) ReusableMessageFactory.INSTANCE.newMessage("foo {}", parameter);
+            // Large enough for the parameters, but smaller than the default reusable array size
+            message.swapParameters(new Object[5]);
+            return parameter;
+        });
     }
 
     private static final class ParameterObject {
+
         private final String value;
-        private final CountDownLatch latch;
 
-        ParameterObject(final String value, final CountDownLatch latch) {
+        private ParameterObject(final String value) {
             this.value = value;
-            this.latch = latch;
         }
 
         @Override
         public String toString() {
             return value;
         }
-
-        @Override
-        protected void finalize() throws Throwable {
-            latch.countDown();
-            super.finalize();
-        }
     }
 }
diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/rolling/RollingAppenderDirectWriteTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/rolling/RollingAppenderDirectWriteTest.java
index 01a2291e2f..4a34ca917c 100644
--- a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/rolling/RollingAppenderDirectWriteTest.java
+++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/rolling/RollingAppenderDirectWriteTest.java
@@ -62,8 +62,11 @@ public class RollingAppenderDirectWriteTest {
                         final InputStream uncompressed = fileName.endsWith(".gz") ? new GZIPInputStream(is) : is;
                         final BufferedReader reader = new BufferedReader(new InputStreamReader(uncompressed, UTF_8))) {
                     String line;
+                    int lineIndex = 0;
                     while ((line = reader.readLine()) != null) {
-                        assertThat(line).matches(LINE_PATTERN);
+                        assertThat(line)
+                                .as("line %d of file `%s`", ++lineIndex, file)
+                                .matches(LINE_PATTERN);
                         ++found;
                     }
                 }
diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/impl/NestedLoggingFromThrowableMessageTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/impl/NestedLoggingFromThrowableMessageTest.java
index 8f1c6c6d9c..108b08b19e 100644
--- a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/impl/NestedLoggingFromThrowableMessageTest.java
+++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/impl/NestedLoggingFromThrowableMessageTest.java
@@ -21,9 +21,9 @@ import static org.junit.Assert.assertTrue;
 
 import java.io.BufferedReader;
 import java.io.File;
-import java.io.FileInputStream;
 import java.io.IOException;
 import java.io.InputStreamReader;
+import java.nio.file.Files;
 import java.util.HashSet;
 import java.util.Set;
 import org.apache.logging.log4j.Logger;
@@ -78,14 +78,15 @@ public class NestedLoggingFromThrowableMessageTest {
         final Set<String> lines2 = readUniqueLines(file2);
 
         assertEquals("Expected the same data from both appenders", lines1, lines2);
-        assertEquals(3, lines1.size());
+        assertEquals(2, lines1.size());
         assertTrue(lines1.contains("INFO NestedLoggingFromThrowableMessageTest Logging in getMessage "));
         assertTrue(lines1.contains("ERROR NestedLoggingFromThrowableMessageTest Test message"));
     }
 
     private static Set<String> readUniqueLines(final File input) throws IOException {
         final Set<String> lines = new HashSet<>();
-        try (final BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(input)))) {
+        try (final BufferedReader reader =
+                new BufferedReader(new InputStreamReader(Files.newInputStream(input.toPath())))) {
             String line;
             while ((line = reader.readLine()) != null) {
                 assertTrue("Read duplicate line: " + line, lines.add(line));
diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/internal/GcHelperTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/internal/GcHelperTest.java
new file mode 100644
index 0000000000..ae994466fe
--- /dev/null
+++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/internal/GcHelperTest.java
@@ -0,0 +1,29 @@
+/*
+ * 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.logging.log4j.core.internal;
+
+import static org.apache.logging.log4j.core.test.internal.GcHelper.awaitGarbageCollection;
+
+import org.junit.jupiter.api.Test;
+
+class GcHelperTest {
+
+    @Test
+    void await_should_work() throws InterruptedException {
+        awaitGarbageCollection(Object::new);
+    }
+}
diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/layout/PatternLayoutDefaultExceptionHandlerTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/layout/PatternLayoutDefaultExceptionHandlerTest.java
new file mode 100644
index 0000000000..6a5fcbac12
--- /dev/null
+++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/layout/PatternLayoutDefaultExceptionHandlerTest.java
@@ -0,0 +1,75 @@
+/*
+ * 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.logging.log4j.core.layout;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.apache.logging.log4j.core.Layout;
+import org.apache.logging.log4j.core.LogEvent;
+import org.apache.logging.log4j.core.config.Configuration;
+import org.apache.logging.log4j.core.config.NullConfiguration;
+import org.apache.logging.log4j.core.impl.Log4jLogEvent;
+import org.assertj.core.api.AbstractStringAssert;
+import org.junit.jupiter.api.Test;
+
+class PatternLayoutDefaultExceptionHandlerTest {
+
+    private static final Configuration CONFIG = new NullConfiguration();
+
+    private static final Exception EXCEPTION = new RuntimeException("foo");
+
+    @Test
+    void default_exception_handler_should_be_provided() {
+        final String threadName = Thread.currentThread().getName();
+        final String exceptionClassName = EXCEPTION.getClass().getCanonicalName();
+        final String exceptionMessage = EXCEPTION.getMessage();
+        final String firstLine = String.format("%s%n%s: %s", threadName, exceptionClassName, exceptionMessage);
+        assertThatPatternEncodes("%t", true).startsWith(firstLine);
+    }
+
+    @Test
+    void default_exception_handler_should_be_provided_after_newline() {
+        final String threadName = Thread.currentThread().getName();
+        final String exceptionClassName = EXCEPTION.getClass().getCanonicalName();
+        final String exceptionMessage = EXCEPTION.getMessage();
+        final String firstLine = String.format("%s%n%s: %s", threadName, exceptionClassName, exceptionMessage);
+        assertThatPatternEncodes("%t%n", true).startsWith(firstLine);
+    }
+
+    @Test
+    void default_exception_handler_should_not_be_provided_if_user_provides_one() {
+        final String className = EXCEPTION.getStackTrace()[0].getClassName();
+        assertThatPatternEncodes("%ex{short.className}", true).isEqualTo(className);
+    }
+
+    @Test
+    void default_exception_handler_should_not_be_provided_if_alwaysWriteExceptions_disabled() {
+        final String threadName = Thread.currentThread().getName();
+        assertThatPatternEncodes("%t", false).isEqualTo(threadName);
+    }
+
+    private static AbstractStringAssert<?> assertThatPatternEncodes(
+            final String pattern, final boolean alwaysWriteExceptions) {
+        final Layout layout = PatternLayout.newBuilder()
+                .setConfiguration(CONFIG)
+                .setPattern(pattern)
+                .setAlwaysWriteExceptions(alwaysWriteExceptions)
+                .build();
+        final LogEvent event = Log4jLogEvent.newBuilder().setThrown(EXCEPTION).build();
+        return assertThat(layout.toSerializable(event)).as("pattern=`%s`", pattern);
+    }
+}
diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/ExtendedThrowablePatternConverterTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/ExtendedThrowablePatternConverterTest.java
index b1418c62e4..ea71b62544 100644
--- a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/ExtendedThrowablePatternConverterTest.java
+++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/ExtendedThrowablePatternConverterTest.java
@@ -17,6 +17,7 @@
 package org.apache.logging.log4j.core.pattern;
 
 import static java.util.Arrays.asList;
+import static org.apache.logging.log4j.core.pattern.ThrowablePatternConverterTest.THROWING_METHOD;
 
 import foo.TestFriendlyException;
 import java.util.List;
@@ -36,7 +37,7 @@ class ExtendedThrowablePatternConverterTest {
     class PropertyTest extends AbstractPropertyTest {
 
         PropertyTest() {
-            super("%xEx");
+            super("%xEx", THROWING_METHOD);
         }
     }
 
@@ -45,7 +46,7 @@ class ExtendedThrowablePatternConverterTest {
             "	at " + TestFriendlyException.NAMED_MODULE_STACK_TRACE_ELEMENT + " ~[?:?]",
             "	at foo.TestFriendlyException.create(TestFriendlyException.java:0) ~[test-classes/:?]",
             "	at foo.TestFriendlyException.<clinit>(TestFriendlyException.java:0) ~[test-classes/:?]",
-            "	at org.apache.logging.log4j.core.pattern.ThrowablePatternConverterTest.<clinit>(ThrowablePatternConverterTest.java:0) [test-classes/:?]",
+            "	at " + TestFriendlyException.ORG_APACHE_REPLACEMENT_STACK_TRACE_ELEMENT + " ~[?:0]",
             "	Suppressed: foo.TestFriendlyException: r_s [localized]",
             "		at foo.TestFriendlyException.create(TestFriendlyException.java:0) ~[test-classes/:?]",
             "		at foo.TestFriendlyException.create(TestFriendlyException.java:0) ~[test-classes/:?]",
@@ -109,7 +110,7 @@ class ExtendedThrowablePatternConverterTest {
                             "foo.TestFriendlyException: r [localized]",
                             "	at " + TestFriendlyException.NAMED_MODULE_STACK_TRACE_ELEMENT + " ~[?:?]",
                             "	... suppressed 2 lines",
-                            "	at org.apache.logging.log4j.core.pattern.ThrowablePatternConverterTest.<clinit>(ThrowablePatternConverterTest.java:0) [test-classes/:?]",
+                            "	at " + TestFriendlyException.ORG_APACHE_REPLACEMENT_STACK_TRACE_ELEMENT + " ~[?:0]",
                             "	Suppressed: foo.TestFriendlyException: r_s [localized]",
                             "		... suppressed 2 lines",
                             "		... 2 more",
@@ -136,7 +137,7 @@ class ExtendedThrowablePatternConverterTest {
         @MethodSource("org.apache.logging.log4j.core.pattern.ThrowablePatternConverterTest#depthTestCases")
         void depth_and_package_limited_output_should_match_2(final DepthTestCase depthTestCase) {
             final String pattern = String.format(
-                    "%s{%d}{filters(org.apache)}%s",
+                    "%s{%d}{filters(bar)}%s",
                     patternPrefix, depthTestCase.maxLineCount, depthTestCase.separatorTestCase.patternAddendum);
             assertStackTraceLines(
                     depthTestCase,
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 b02618aff4..b7cee50b3a 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
@@ -17,13 +17,19 @@
 package org.apache.logging.log4j.core.pattern;
 
 import static java.util.Arrays.asList;
+import static org.apache.logging.log4j.core.pattern.ThrowablePatternConverterTest.EXCEPTION;
+import static org.apache.logging.log4j.core.pattern.ThrowablePatternConverterTest.LEVEL;
+import static org.apache.logging.log4j.core.pattern.ThrowablePatternConverterTest.convert;
+import static org.assertj.core.api.Assertions.assertThat;
 
 import foo.TestFriendlyException;
 import java.util.List;
 import org.apache.logging.log4j.core.pattern.ThrowablePatternConverterTest.AbstractPropertyTest;
 import org.apache.logging.log4j.core.pattern.ThrowablePatternConverterTest.AbstractStackTraceTest;
 import org.apache.logging.log4j.core.pattern.ThrowablePatternConverterTest.DepthTestCase;
+import org.apache.logging.log4j.core.util.Throwables;
 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;
 
@@ -32,11 +38,14 @@ import org.junit.jupiter.params.provider.MethodSource;
  */
 class RootThrowablePatternConverterTest {
 
+    private static final StackTraceElement THROWING_METHOD =
+            Throwables.getRootCause(EXCEPTION).getStackTrace()[0];
+
     @Nested
     class PropertyTest extends AbstractPropertyTest {
 
         PropertyTest() {
-            super("%rEx");
+            super("%rEx", THROWING_METHOD);
         }
     }
 
@@ -59,7 +68,7 @@ class RootThrowablePatternConverterTest {
             "	at " + TestFriendlyException.NAMED_MODULE_STACK_TRACE_ELEMENT,
             "	at foo.TestFriendlyException.create(TestFriendlyException.java:0)",
             "	at foo.TestFriendlyException.<clinit>(TestFriendlyException.java:0)",
-            "	at org.apache.logging.log4j.core.pattern.ThrowablePatternConverterTest.<clinit>(ThrowablePatternConverterTest.java:0)",
+            "	at " + TestFriendlyException.ORG_APACHE_REPLACEMENT_STACK_TRACE_ELEMENT,
             "	Suppressed: foo.TestFriendlyException: r_s_c [localized]",
             "		at foo.TestFriendlyException.create(TestFriendlyException.java:0)",
             "		at foo.TestFriendlyException.create(TestFriendlyException.java:0)",
@@ -80,6 +89,16 @@ class RootThrowablePatternConverterTest {
             super("%rEx");
         }
 
+        @Test
+        @Override
+        void output_should_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());
+            assertThat(stackTrace).as("pattern=`%s`", pattern).startsWith(expectedStart);
+        }
+
         @ParameterizedTest
         @MethodSource("org.apache.logging.log4j.core.pattern.ThrowablePatternConverterTest#fullStackTracePatterns")
         void full_output_should_match(final String pattern) {
@@ -120,7 +139,7 @@ class RootThrowablePatternConverterTest {
                             "Wrapped by: foo.TestFriendlyException: r [localized]",
                             "	at " + TestFriendlyException.NAMED_MODULE_STACK_TRACE_ELEMENT,
                             "	... suppressed 2 lines",
-                            "	at org.apache.logging.log4j.core.pattern.ThrowablePatternConverterTest.<clinit>(ThrowablePatternConverterTest.java:0)",
+                            "	at " + TestFriendlyException.ORG_APACHE_REPLACEMENT_STACK_TRACE_ELEMENT,
                             "	Suppressed: foo.TestFriendlyException: r_s_c [localized]",
                             "		... suppressed 2 lines",
                             "		... 3 more",
@@ -136,7 +155,7 @@ class RootThrowablePatternConverterTest {
         @MethodSource("org.apache.logging.log4j.core.pattern.ThrowablePatternConverterTest#depthTestCases")
         void depth_and_package_limited_output_should_match_2(final DepthTestCase depthTestCase) {
             final String pattern = String.format(
-                    "%s{%d}{filters(org.apache)}%s",
+                    "%s{%d}{filters(bar)}%s",
                     patternPrefix, depthTestCase.maxLineCount, depthTestCase.separatorTestCase.patternAddendum);
             assertStackTraceLines(
                     depthTestCase,
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 c886def43c..e178526fd9 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
@@ -17,6 +17,7 @@
 package org.apache.logging.log4j.core.pattern;
 
 import static java.util.Arrays.asList;
+import static org.apache.logging.log4j.util.Strings.LINE_SEPARATOR;
 import static org.assertj.core.api.Assertions.assertThat;
 
 import foo.TestFriendlyException;
@@ -33,6 +34,7 @@ import org.apache.logging.log4j.core.impl.Log4jLogEvent;
 import org.apache.logging.log4j.core.layout.PatternLayout;
 import org.jspecify.annotations.Nullable;
 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;
 
@@ -41,15 +43,13 @@ import org.junit.jupiter.params.provider.MethodSource;
  */
 public class ThrowablePatternConverterTest {
 
-    private static final String NEWLINE = System.lineSeparator();
+    static final Throwable EXCEPTION = TestFriendlyException.INSTANCE;
 
-    private static final Throwable EXCEPTION = TestFriendlyException.INSTANCE;
-
-    private static final StackTraceElement THROWING_METHOD = EXCEPTION.getStackTrace()[0];
+    static final StackTraceElement THROWING_METHOD = EXCEPTION.getStackTrace()[0];
 
     private static final PatternParser PATTERN_PARSER = PatternLayout.createPatternParser(null);
 
-    private static final Level LEVEL = Level.FATAL;
+    static final Level LEVEL = Level.FATAL;
 
     static final class SeparatorTestCase {
 
@@ -75,10 +75,10 @@ public class ThrowablePatternConverterTest {
                 new SeparatorTestCase("{separator()}", ""),
                 new SeparatorTestCase("{separator(#)}", "#"),
                 // Only suffixes
-                new SeparatorTestCase("{suffix()}", NEWLINE),
-                new SeparatorTestCase("{suffix(~)}", " ~" + NEWLINE),
-                new SeparatorTestCase("{suffix(%level)}", " " + level + NEWLINE),
-                new SeparatorTestCase("{suffix(%rEx)}", NEWLINE),
+                new SeparatorTestCase("{suffix()}", LINE_SEPARATOR),
+                new SeparatorTestCase("{suffix(~)}", " ~" + LINE_SEPARATOR),
+                new SeparatorTestCase("{suffix(%level)}", " " + level + LINE_SEPARATOR),
+                new SeparatorTestCase("{suffix(%rEx)}", LINE_SEPARATOR),
                 // Both separators and suffixes
                 new SeparatorTestCase("{separator()}{suffix()}", ""),
                 new SeparatorTestCase("{separator()}{suffix(~)}", " ~"),
@@ -94,7 +94,7 @@ public class ThrowablePatternConverterTest {
     class PropertyTest extends AbstractPropertyTest {
 
         PropertyTest() {
-            super("%ex");
+            super("%ex", THROWING_METHOD);
         }
     }
 
@@ -102,8 +102,11 @@ public class ThrowablePatternConverterTest {
 
         private final String patternPrefix;
 
-        AbstractPropertyTest(final String patternPrefix) {
+        private final StackTraceElement throwingMethod;
+
+        AbstractPropertyTest(final String patternPrefix, final StackTraceElement throwingMethod) {
             this.patternPrefix = patternPrefix;
+            this.throwingMethod = throwingMethod;
         }
 
         @ParameterizedTest
@@ -121,37 +124,34 @@ public class ThrowablePatternConverterTest {
         @ParameterizedTest
         @MethodSource("org.apache.logging.log4j.core.pattern.ThrowablePatternConverterTest#separatorTestCases")
         void className_should_be_rendered(final SeparatorTestCase separatorTestCase) {
-            assertConversion(separatorTestCase, "{short.className}", THROWING_METHOD.getClassName());
+            assertConversion(separatorTestCase, "{short.className}", throwingMethod.getClassName());
         }
 
         @ParameterizedTest
         @MethodSource("org.apache.logging.log4j.core.pattern.ThrowablePatternConverterTest#separatorTestCases")
         void methodName_should_be_rendered(final SeparatorTestCase separatorTestCase) {
-            assertConversion(separatorTestCase, "{short.methodName}", THROWING_METHOD.getMethodName());
+            assertConversion(separatorTestCase, "{short.methodName}", throwingMethod.getMethodName());
         }
 
         @ParameterizedTest
         @MethodSource("org.apache.logging.log4j.core.pattern.ThrowablePatternConverterTest#separatorTestCases")
         void lineNumber_should_be_rendered(final SeparatorTestCase separatorTestCase) {
-            assertConversion(separatorTestCase, "{short.lineNumber}", THROWING_METHOD.getLineNumber() + "");
+            assertConversion(separatorTestCase, "{short.lineNumber}", throwingMethod.getLineNumber() + "");
         }
 
         @ParameterizedTest
         @MethodSource("org.apache.logging.log4j.core.pattern.ThrowablePatternConverterTest#separatorTestCases")
         void fileName_should_be_rendered(final SeparatorTestCase separatorTestCase) {
-            assertConversion(separatorTestCase, "{short.fileName}", THROWING_METHOD.getFileName());
+            assertConversion(separatorTestCase, "{short.fileName}", throwingMethod.getFileName());
         }
 
         private void assertConversion(
                 final SeparatorTestCase separatorTestCase, final String pattern, final Object expectedOutput) {
             final String effectivePattern = patternPrefix + pattern + separatorTestCase.patternAddendum;
             final String output = convert(effectivePattern);
-            final String effectiveExpectedOutput = expectedOutput + separatorTestCase.conversionEnding;
             assertThat(output)
-                    .as(
-                            "pattern=`%s`, separatorTestCase=%s, expectedOutput=`%s`",
-                            pattern, separatorTestCase, expectedOutput)
-                    .isEqualTo(effectiveExpectedOutput);
+                    .as("pattern=`%s`, separatorTestCase=%s", pattern, separatorTestCase)
+                    .isEqualTo(expectedOutput);
         }
     }
 
@@ -182,7 +182,7 @@ public class ThrowablePatternConverterTest {
     }
 
     static Stream<String> fullStackTracePatterns() {
-        return Stream.of("", "{}", "{full}", "{" + Integer.MAX_VALUE + "}", "{separator(" + NEWLINE + ")}");
+        return Stream.of("", "{}", "{full}", "{" + Integer.MAX_VALUE + "}", "{separator(" + LINE_SEPARATOR + ")}");
     }
 
     @Nested
@@ -230,8 +230,8 @@ public class ThrowablePatternConverterTest {
             int lineCount = 0;
             int startIndex = 0;
             int newlineIndex;
-            while (lineCount < maxLineCount && (newlineIndex = text.indexOf(NEWLINE, startIndex)) != -1) {
-                final int endIndex = newlineIndex + NEWLINE.length();
+            while (lineCount < maxLineCount && (newlineIndex = text.indexOf(LINE_SEPARATOR, startIndex)) != -1) {
+                final int endIndex = newlineIndex + LINE_SEPARATOR.length();
                 final String line = text.substring(startIndex, endIndex);
                 buffer.append(line);
                 lineCount++;
@@ -266,7 +266,7 @@ public class ThrowablePatternConverterTest {
                             "	at " + TestFriendlyException.NAMED_MODULE_STACK_TRACE_ELEMENT,
                             "	at foo.TestFriendlyException.create(TestFriendlyException.java:0)",
                             "	at foo.TestFriendlyException.<clinit>(TestFriendlyException.java:0)",
-                            "	at org.apache.logging.log4j.core.pattern.ThrowablePatternConverterTest.<clinit>(ThrowablePatternConverterTest.java:0)",
+                            "	at " + TestFriendlyException.ORG_APACHE_REPLACEMENT_STACK_TRACE_ELEMENT,
                             "	Suppressed: foo.TestFriendlyException: r_s [localized]",
                             "		at foo.TestFriendlyException.create(TestFriendlyException.java:0)",
                             "		at foo.TestFriendlyException.create(TestFriendlyException.java:0)",
@@ -308,7 +308,7 @@ public class ThrowablePatternConverterTest {
                             "foo.TestFriendlyException: r [localized]",
                             "	at " + TestFriendlyException.NAMED_MODULE_STACK_TRACE_ELEMENT,
                             "	... suppressed 2 lines",
-                            "	at org.apache.logging.log4j.core.pattern.ThrowablePatternConverterTest.<clinit>(ThrowablePatternConverterTest.java:0)",
+                            "	at " + TestFriendlyException.ORG_APACHE_REPLACEMENT_STACK_TRACE_ELEMENT,
                             "	Suppressed: foo.TestFriendlyException: r_s [localized]",
                             "		... suppressed 2 lines",
                             "		... 2 more",
@@ -335,7 +335,7 @@ public class ThrowablePatternConverterTest {
         @MethodSource("org.apache.logging.log4j.core.pattern.ThrowablePatternConverterTest#depthTestCases")
         void depth_and_package_limited_output_should_match_2(final DepthTestCase depthTestCase) {
             final String pattern = String.format(
-                    "%s{%d}{filters(org.apache)}%s",
+                    "%s{%d}{filters(bar)}%s",
                     patternPrefix, depthTestCase.maxLineCount, depthTestCase.separatorTestCase.patternAddendum);
             assertStackTraceLines(
                     depthTestCase,
@@ -383,6 +383,15 @@ public class ThrowablePatternConverterTest {
             this.patternPrefix = patternPrefix;
         }
 
+        @Test
+        void output_should_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());
+            assertThat(stackTrace).as("pattern=`%s`", pattern).startsWith(expectedStart);
+        }
+
         @ParameterizedTest
         @MethodSource("org.apache.logging.log4j.core.pattern.ThrowablePatternConverterTest#separatorTestCases")
         void none_output_should_be_empty(final SeparatorTestCase separatorTestCase) {
@@ -400,7 +409,7 @@ public class ThrowablePatternConverterTest {
             final String conversionEnding;
             if (depthTestCase == null) {
                 maxLineCount = Integer.MAX_VALUE;
-                conversionEnding = NEWLINE;
+                conversionEnding = LINE_SEPARATOR;
             } else {
                 maxLineCount = depthTestCase.maxLineCount;
                 conversionEnding = depthTestCase.separatorTestCase.conversionEnding;
@@ -426,14 +435,14 @@ public class ThrowablePatternConverterTest {
         }
     }
 
-    private static String convert(final String pattern) {
+    static String convert(final String pattern) {
         final List<PatternFormatter> patternFormatters = PATTERN_PARSER.parse(pattern, false, true, true);
-        assertThat(patternFormatters).hasSize(1);
-        final PatternFormatter patternFormatter = patternFormatters.get(0);
         final LogEvent logEvent =
                 Log4jLogEvent.newBuilder().setThrown(EXCEPTION).setLevel(LEVEL).build();
         final StringBuilder buffer = new StringBuilder();
-        patternFormatter.format(logEvent, buffer);
+        for (final PatternFormatter patternFormatter : patternFormatters) {
+            patternFormatter.format(logEvent, buffer);
+        }
         return buffer.toString();
     }
 }
diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/ThrowableTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/ThrowableTest.java
deleted file mode 100644
index 3b25dce8da..0000000000
--- a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/ThrowableTest.java
+++ /dev/null
@@ -1,229 +0,0 @@
-/*
- * 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.logging.log4j.core.pattern;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-import java.io.PrintWriter;
-import java.util.Collections;
-import java.util.stream.Stream;
-import org.apache.logging.log4j.Level;
-import org.apache.logging.log4j.Logger;
-import org.apache.logging.log4j.core.LoggerContext;
-import org.apache.logging.log4j.core.LoggerTest;
-import org.apache.logging.log4j.core.config.Configuration;
-import org.apache.logging.log4j.core.config.Configurator;
-import org.apache.logging.log4j.core.config.builder.api.ConfigurationBuilder;
-import org.apache.logging.log4j.core.config.builder.api.ConfigurationBuilderFactory;
-import org.apache.logging.log4j.core.config.builder.impl.BuiltConfiguration;
-import org.apache.logging.log4j.core.test.appender.ListAppender;
-import org.apache.logging.log4j.core.util.StringBuilderWriter;
-import org.junit.Test;
-import org.junit.jupiter.params.ParameterizedTest;
-import org.junit.jupiter.params.provider.Arguments;
-import org.junit.jupiter.params.provider.MethodSource;
-
-/**
- * Unit tests for {@code throwable}, {@code rThrowable} and {@code xThrowable} pattern.
- */
-public class ThrowableTest {
-    static Stream<Arguments> testConverter_dataSource() {
-        final String filters = "org.junit,org.apache.maven,sun.reflect,java.lang.reflect";
-        final Integer depth = 5;
-        return Stream.of(
-                // Throwable
-                Arguments.of("%ex", filters, null, null, null),
-                Arguments.of("%ex", null, depth, null, null),
-                Arguments.of("%ex", null, null, "I am suffix", "#"),
-                // RootThrowable
-                Arguments.of("%rEx", filters, null, null, null),
-                Arguments.of("%rEx", null, depth, null, null),
-                Arguments.of("%rEx", null, null, "I am suffix", "#"),
-                // ExtendedThrowable
-                Arguments.of("%xEx", filters, null, null, null),
-                Arguments.of("%xEx", null, depth, null, null),
-                Arguments.of("%xEx", null, null, "I am suffix", "#"));
-    }
-
-    @ParameterizedTest
-    @MethodSource("testConverter_dataSource")
-    void testConverter(String exceptionPattern, String filters, Integer depth, String suffix, String lineSeparator) {
-        final String pattern = buildPattern(exceptionPattern, filters, depth, suffix, lineSeparator);
-        final ConfigurationBuilder<BuiltConfiguration> configBuilder =
-                ConfigurationBuilderFactory.newConfigurationBuilder();
-
-        final String appenderName = "LIST";
-        final Configuration config = configBuilder
-                .add(configBuilder
-                        .newAppender(appenderName, "List")
-                        .add(configBuilder.newLayout("PatternLayout").addAttribute("pattern", pattern)))
-                .add(configBuilder.newRootLogger(Level.ALL).add(configBuilder.newAppenderRef(appenderName)))
-                .build(false);
-
-        try (final LoggerContext loggerContext = Configurator.initialize(config)) {
-            // Restart logger context after first test run
-            if (loggerContext.isStopped()) {
-                loggerContext.start();
-                loggerContext.reconfigure(config);
-            }
-            final Throwable r = createException("r", 1, 3);
-
-            final Logger logger = loggerContext.getLogger(LoggerTest.class);
-            final ListAppender appender = loggerContext.getConfiguration().getAppender(appenderName);
-            logger.error("Exception", r);
-
-            assertThat(appender.getMessages()).hasSize(1);
-            final String message = appender.getMessages().get(0);
-            assertThat(message).isNotNull();
-            verifyFilters(message, filters);
-            verifyDepth(message, depth);
-            verifySuffix(message, suffix, lineSeparator);
-        }
-    }
-
-    static Stream<Arguments> renderers_dataSource() {
-        return Stream.of(
-                Arguments.of(new ThrowableStackTraceRenderer<>(Collections.emptyList(), Integer.MAX_VALUE)),
-                Arguments.of(new ThrowableInvertedStackTraceRenderer(Collections.emptyList(), Integer.MAX_VALUE)),
-                Arguments.of(new ThrowableExtendedStackTraceRenderer(Collections.emptyList(), Integer.MAX_VALUE)));
-    }
-
-    @ParameterizedTest
-    @MethodSource("renderers_dataSource")
-    void testCircularSuppressedExceptions(final ThrowableStackTraceRenderer<?> renderer) {
-        final Exception e1 = new Exception();
-        final Exception e2 = new Exception();
-        e2.addSuppressed(e1);
-        e1.addSuppressed(e2);
-
-        render(renderer, e1);
-    }
-
-    @ParameterizedTest
-    @MethodSource("renderers_dataSource")
-    void testCircularSuppressedNestedException(final ThrowableStackTraceRenderer<?> renderer) {
-        final Exception e1 = new Exception();
-        final Exception e2 = new Exception(e1);
-        e2.addSuppressed(e1);
-        e1.addSuppressed(e2);
-
-        render(renderer, e1);
-    }
-
-    @ParameterizedTest
-    @MethodSource("renderers_dataSource")
-    void testCircularCauseExceptions(final ThrowableStackTraceRenderer<?> renderer) {
-        final Exception e1 = new Exception();
-        final Exception e2 = new Exception(e1);
-        e1.initCause(e2);
-        render(renderer, e1);
-    }
-
-    /**
-     * Default setting ThrowableRenderer render output should equal to throwable.printStackTrace().
-     */
-    @Test
-    public void testThrowableRenderer() {
-        final Throwable throwable = createException("r", 1, 3);
-        final ThrowableStackTraceRenderer<?> renderer =
-                new ThrowableStackTraceRenderer<>(Collections.emptyList(), Integer.MAX_VALUE);
-        String actual = render(renderer, throwable);
-        assertThat(actual).isEqualTo(getStandardThrowableStackTrace(throwable));
-    }
-
-    private static String render(final ThrowableStackTraceRenderer<?> renderer, final Throwable throwable) {
-        final StringBuilder stringBuilder = new StringBuilder();
-        renderer.renderThrowable(stringBuilder, throwable, System.lineSeparator());
-        return stringBuilder.toString();
-    }
-
-    private static String getStandardThrowableStackTrace(final Throwable throwable) {
-        final StringBuilder buffer = new StringBuilder();
-        final PrintWriter printWriter = new PrintWriter(new StringBuilderWriter(buffer));
-        throwable.printStackTrace(printWriter);
-        return buffer.toString();
-    }
-
-    private static String buildPattern(
-            final String exceptionPattern,
-            final String filters,
-            final Integer depth,
-            final String suffix,
-            final String lineSeparator) {
-        final StringBuilder buffer = new StringBuilder("%m");
-        buffer.append(exceptionPattern);
-        if (filters != null) {
-            buffer.append("{filters(");
-            buffer.append(filters);
-            buffer.append(")}");
-        }
-
-        if (depth != null) {
-            buffer.append("{");
-            buffer.append(depth);
-            buffer.append("}");
-        }
-
-        if (suffix != null) {
-            buffer.append("{suffix(");
-            buffer.append(suffix);
-            buffer.append(")}");
-        }
-
-        if (lineSeparator != null) {
-            buffer.append("{separator(");
-            buffer.append(lineSeparator);
-            buffer.append(")}");
-        }
-        return buffer.toString();
-    }
-
-    private static void verifyFilters(final String message, final String filters) {
-        if (filters != null) {
-            assertThat(message).contains("suppressed");
-            final String[] filterArray = filters.split(",");
-            for (final String filter : filterArray) {
-                assertThat(message).doesNotContain(filter);
-            }
-        } else {
-            assertThat(message).doesNotContain("suppressed");
-        }
-    }
-
-    private static void verifyDepth(final String message, final Integer depth) {
-        if (depth != null) {
-            assertThat(message).hasLineCount(depth);
-        }
-    }
-
-    private static void verifySuffix(final String message, final String suffix, final String lineSeparator) {
-        if (suffix != null && lineSeparator != null) {
-            for (String line : message.split(lineSeparator)) {
-                assertThat(line).endsWith(suffix);
-            }
-        }
-    }
-
-    private static Throwable createException(final String name, int depth, int maxDepth) {
-        Exception r = new Exception(name);
-        if (depth < maxDepth) {
-            r.initCause(createException(name + "_c", depth + 1, maxDepth));
-            r.addSuppressed(createException(name + "_s", depth + 1, maxDepth));
-        }
-        return r;
-    }
-}
diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/util/ThrowablesTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/util/ThrowablesTest.java
index 4d3cdfec3e..912d62a1d9 100644
--- a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/util/ThrowablesTest.java
+++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/util/ThrowablesTest.java
@@ -21,51 +21,50 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
 
 import org.junit.jupiter.api.Test;
 
-public class ThrowablesTest {
+class ThrowablesTest {
 
     @Test
-    public void testGetRootCauseNone() {
+    void testGetRootCauseNone() {
         final NullPointerException throwable = new NullPointerException();
         assertEquals(throwable, Throwables.getRootCause(throwable));
     }
 
     @Test
-    public void testGetRootCauseDepth1() {
+    void testGetRootCauseDepth1() {
         final Throwable cause = new NullPointerException();
         final Throwable error = new UnsupportedOperationException(cause);
         assertEquals(cause, Throwables.getRootCause(error));
     }
 
     @Test
-    public void testGetRootCauseDepth2() {
+    void testGetRootCauseDepth2() {
         final Throwable rootCause = new NullPointerException();
         final Throwable cause = new UnsupportedOperationException(rootCause);
         final Throwable error = new IllegalArgumentException(cause);
         assertEquals(rootCause, Throwables.getRootCause(error));
     }
 
-    @SuppressWarnings("ThrowableNotThrown")
     @Test
-    public void testGetRootCauseLoop() {
+    void testGetRootCauseLoop() {
         final Throwable cause1 = new RuntimeException();
         final Throwable cause2 = new RuntimeException(cause1);
         final Throwable cause3 = new RuntimeException(cause2);
         cause1.initCause(cause3);
-        assertThrows(IllegalArgumentException.class, () -> Throwables.getRootCause(cause3));
+        assertEquals(cause1, Throwables.getRootCause(cause3));
     }
 
     @Test
-    public void testRethrowRuntimeException() {
+    void testRethrowRuntimeException() {
         assertThrows(NullPointerException.class, () -> Throwables.rethrow(new NullPointerException()));
     }
 
     @Test
-    public void testRethrowError() {
+    void testRethrowError() {
         assertThrows(UnknownError.class, () -> Throwables.rethrow(new UnknownError()));
     }
 
     @Test
-    public void testRethrowCheckedException() {
+    void testRethrowCheckedException() {
         assertThrows(NoSuchMethodException.class, () -> Throwables.rethrow(new NoSuchMethodException()));
     }
 }
diff --git a/log4j-core-test/src/test/resources/EventParameterMemoryLeakTest.xml b/log4j-core-test/src/test/resources/EventParameterMemoryLeakTest.xml
deleted file mode 100644
index 54554cd97d..0000000000
--- a/log4j-core-test/src/test/resources/EventParameterMemoryLeakTest.xml
+++ /dev/null
@@ -1,35 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-  ~ 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.
-  -->
-<Configuration status="off">
-  <Appenders>
-    <File name="File"
-          fileName="target/EventParameterMemoryLeakTest.log"
-          bufferedIO="false"
-          immediateFlush="true"
-          append="false">
-      <PatternLayout>
-        <Pattern>%d %p %c{1.} [%t] %m %throwable{short.message}%n</Pattern>
-      </PatternLayout>
-    </File>
-  </Appenders>
-  <Loggers>
-    <Root level="trace">
-      <AppenderRef ref="File"/>
-    </Root>
-  </Loggers>
-</Configuration>
diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ExtendedThrowablePatternConverter.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ExtendedThrowablePatternConverter.java
index edeed93282..954cf25037 100644
--- a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ExtendedThrowablePatternConverter.java
+++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ExtendedThrowablePatternConverter.java
@@ -33,7 +33,13 @@ import org.jspecify.annotations.Nullable;
 public final class ExtendedThrowablePatternConverter extends ThrowablePatternConverter {
 
     private ExtendedThrowablePatternConverter(@Nullable final Configuration config, @Nullable final String[] options) {
-        super("ExtendedThrowable", "throwable", options, config, ExtendedThrowablePatternConverter::createRenderer);
+        super(
+                "ExtendedThrowable",
+                "throwable",
+                options,
+                config,
+                ThrowablePropertyRendererFactory.INSTANCE,
+                ThrowableExtendedStackTraceRendererFactory.INSTANCE);
     }
 
     private static ThrowableExtendedStackTraceRenderer createRenderer(final ThrowableFormatOptions options) {
diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/RootThrowablePatternConverter.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/RootThrowablePatternConverter.java
index 8b032a893d..aae942d7a4 100644
--- a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/RootThrowablePatternConverter.java
+++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/RootThrowablePatternConverter.java
@@ -17,7 +17,6 @@
 package org.apache.logging.log4j.core.pattern;
 
 import org.apache.logging.log4j.core.config.Configuration;
-import org.apache.logging.log4j.core.impl.ThrowableFormatOptions;
 import org.apache.logging.log4j.plugins.Namespace;
 import org.apache.logging.log4j.plugins.Plugin;
 import org.jspecify.annotations.NullMarked;
@@ -33,11 +32,13 @@ import org.jspecify.annotations.Nullable;
 public final class RootThrowablePatternConverter extends ThrowablePatternConverter {
 
     private RootThrowablePatternConverter(@Nullable final Configuration config, @Nullable final String[] options) {
-        super("RootThrowable", "throwable", options, config, RootThrowablePatternConverter::createRenderer);
-    }
-
-    private static ThrowableInvertedStackTraceRenderer createRenderer(final ThrowableFormatOptions options) {
-        return new ThrowableInvertedStackTraceRenderer(options.getIgnorePackages(), options.getLines());
+        super(
+                "RootThrowable",
+                "throwable",
+                options,
+                config,
+                ThrowableInvertedPropertyRendererFactory.INSTANCE,
+                ThrowableInvertedStackTraceRendererFactory.INSTANCE);
     }
 
     /**
diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowableExtendedStackTraceRendererFactory.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowableExtendedStackTraceRendererFactory.java
new file mode 100644
index 0000000000..d95399c21b
--- /dev/null
+++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowableExtendedStackTraceRendererFactory.java
@@ -0,0 +1,31 @@
+/*
+ * 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.logging.log4j.core.pattern;
+
+import org.apache.logging.log4j.core.impl.ThrowableFormatOptions;
+
+final class ThrowableExtendedStackTraceRendererFactory extends ThrowableStackTraceRendererFactory {
+
+    static final ThrowableExtendedStackTraceRendererFactory INSTANCE = new ThrowableExtendedStackTraceRendererFactory();
+
+    private ThrowableExtendedStackTraceRendererFactory() {}
+
+    @Override
+    ThrowableExtendedStackTraceRenderer createStackTraceRenderer(ThrowableFormatOptions options) {
+        return new ThrowableExtendedStackTraceRenderer(options.getIgnorePackages(), options.getLines());
+    }
+}
diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowableInvertedPropertyRendererFactory.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowableInvertedPropertyRendererFactory.java
new file mode 100644
index 0000000000..4d0e8eaa43
--- /dev/null
+++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowableInvertedPropertyRendererFactory.java
@@ -0,0 +1,40 @@
+/*
+ * 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.logging.log4j.core.pattern;
+
+import org.apache.logging.log4j.core.util.Throwables;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * {@link ThrowablePropertyRendererFactory} implementation where the causal chain will be processed in reverse order.
+ */
+@NullMarked
+final class ThrowableInvertedPropertyRendererFactory extends ThrowablePropertyRendererFactory {
+
+    static final ThrowableInvertedPropertyRendererFactory INSTANCE = new ThrowableInvertedPropertyRendererFactory();
+
+    private ThrowableInvertedPropertyRendererFactory() {
+        super(ThrowableInvertedPropertyRendererFactory::extractThrowingMethod);
+    }
+
+    @Nullable
+    private static StackTraceElement extractThrowingMethod(final Throwable throwable) {
+        final Throwable rootThrowable = Throwables.getRootCause(throwable);
+        return rootThrowable.getStackTrace()[0];
+    }
+}
diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowableInvertedStackTraceRenderer.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowableInvertedStackTraceRenderer.java
index 786016f549..6ad7173462 100644
--- a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowableInvertedStackTraceRenderer.java
+++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowableInvertedStackTraceRenderer.java
@@ -21,7 +21,7 @@ import java.util.Set;
 import org.jspecify.annotations.Nullable;
 
 /**
- * {@link ThrowableStackTraceRenderer} variant where the stack trace causal chain is rendered in reverse order.
+ * {@link ThrowableStackTraceRenderer} variant where the stack trace causal chain is processed in reverse order.
  */
 final class ThrowableInvertedStackTraceRenderer
         extends ThrowableStackTraceRenderer<ThrowableStackTraceRenderer.Context> {
diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowableInvertedStackTraceRendererFactory.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowableInvertedStackTraceRendererFactory.java
new file mode 100644
index 0000000000..d8c61eda9b
--- /dev/null
+++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowableInvertedStackTraceRendererFactory.java
@@ -0,0 +1,31 @@
+/*
+ * 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.logging.log4j.core.pattern;
+
+import org.apache.logging.log4j.core.impl.ThrowableFormatOptions;
+
+final class ThrowableInvertedStackTraceRendererFactory extends ThrowableStackTraceRendererFactory {
+
+    static final ThrowableInvertedStackTraceRendererFactory INSTANCE = new ThrowableInvertedStackTraceRendererFactory();
+
+    private ThrowableInvertedStackTraceRendererFactory() {}
+
+    @Override
+    ThrowableInvertedStackTraceRenderer createStackTraceRenderer(ThrowableFormatOptions options) {
+        return new ThrowableInvertedStackTraceRenderer(options.getIgnorePackages(), options.getLines());
+    }
+}
diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowablePatternConverter.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowablePatternConverter.java
index d22926b781..aa6009ed2f 100644
--- a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowablePatternConverter.java
+++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowablePatternConverter.java
@@ -55,15 +55,15 @@ public class ThrowablePatternConverter extends LogEventPatternConverter {
     private final ThrowableRenderer renderer;
 
     /**
-     * @deprecated Use {@link #ThrowablePatternConverter(String, String, String[], Configuration, Function)} instead.
+     * @deprecated Use {@link #ThrowablePatternConverter(String, String, String[], Configuration, ThrowablePropertyRendererFactory, ThrowableStackTraceRendererFactory)} instead.
      */
     @Deprecated
     protected ThrowablePatternConverter(final String name, final String style, @Nullable final String[] options) {
-        this(name, style, options, null, null);
+        this(name, style, options, null, null, null);
     }
 
     /**
-     * @deprecated Use {@link #ThrowablePatternConverter(String, String, String[], Configuration, Function)} instead.
+     * @deprecated Use {@link #ThrowablePatternConverter(String, String, String[], Configuration, ThrowablePropertyRendererFactory, ThrowableStackTraceRendererFactory)} instead.
      */
     @Deprecated
     protected ThrowablePatternConverter(
@@ -71,7 +71,7 @@ public class ThrowablePatternConverter extends LogEventPatternConverter {
             final String style,
             @Nullable final String[] options,
             @Nullable final Configuration config) {
-        this(name, style, options, config, null);
+        this(name, style, options, config, null, null);
     }
 
     /**
@@ -89,8 +89,8 @@ public class ThrowablePatternConverter extends LogEventPatternConverter {
             final String style,
             @Nullable final String[] options,
             @Nullable final Configuration config,
-            @Nullable
-                    final Function<ThrowableFormatOptions, ThrowableStackTraceRenderer<?>> stackTraceRendererFactory) {
+            @Nullable final ThrowablePropertyRendererFactory propertyRendererFactory,
+            @Nullable final ThrowableStackTraceRendererFactory stackTraceRendererFactory) {
 
         // Process `name`, `style`, and `options`
         super(name, style);
@@ -103,16 +103,8 @@ public class ThrowablePatternConverter extends LogEventPatternConverter {
         this.formatters = Collections.unmodifiableList(suffixFormatters);
 
         // Create the effective renderer
-        final ThrowablePropertyRenderer propertyRenderer = ThrowablePropertyRenderer.fromOptions(options);
-        if (propertyRenderer != null) {
-            this.renderer = propertyRenderer;
-        } else {
-            final Function<ThrowableFormatOptions, ThrowableStackTraceRenderer<?>> effectiveRendererFactory =
-                    stackTraceRendererFactory != null
-                            ? stackTraceRendererFactory
-                            : ThrowablePatternConverter::createRenderer;
-            this.renderer = effectiveRendererFactory.apply(this.options);
-        }
+        this.renderer =
+                createEffectiveRenderer(options, this.options, propertyRendererFactory, stackTraceRendererFactory);
     }
 
     /**
@@ -124,7 +116,7 @@ public class ThrowablePatternConverter extends LogEventPatternConverter {
      */
     public static ThrowablePatternConverter newInstance(
             @Nullable final Configuration config, @Nullable final String[] options) {
-        return new ThrowablePatternConverter("Throwable", "throwable", options, config, null);
+        return new ThrowablePatternConverter("Throwable", "throwable", options, config, null, null);
     }
 
     /**
@@ -137,18 +129,10 @@ public class ThrowablePatternConverter extends LogEventPatternConverter {
         final Throwable throwable = event.getThrown();
         if (throwable != null) {
             final String lineSeparator = effectiveLineSeparatorProvider.apply(event);
-            ensureWhitespaceSuffix(buffer);
             renderer.renderThrowable(buffer, throwable, lineSeparator);
         }
     }
 
-    private static void ensureWhitespaceSuffix(final StringBuilder buffer) {
-        final int bufferLength = buffer.length();
-        if (bufferLength > 0 && !Character.isWhitespace(buffer.charAt(bufferLength - 1))) {
-            buffer.append(' ');
-        }
-    }
-
     /**
      * Indicates this converter handles {@link Throwable}s.
      *
@@ -234,8 +218,25 @@ public class ThrowablePatternConverter extends LogEventPatternConverter {
         }
     }
 
-    private static ThrowableStackTraceRenderer<?> createRenderer(final ThrowableFormatOptions options) {
-        return new ThrowableStackTraceRenderer<>(options.getIgnorePackages(), options.getLines());
+    private static ThrowableRenderer createEffectiveRenderer(
+            final String[] rawOptions,
+            final ThrowableFormatOptions options,
+            @Nullable final ThrowablePropertyRendererFactory propertyRendererFactory,
+            @Nullable final ThrowableStackTraceRendererFactory stackTraceRendererFactory) {
+
+        // Try to create a property renderer first
+        final ThrowablePropertyRendererFactory effectivePropertyRendererFactory =
+                propertyRendererFactory != null ? propertyRendererFactory : ThrowablePropertyRendererFactory.INSTANCE;
+        final ThrowableRenderer propertyRenderer = effectivePropertyRendererFactory.createPropertyRenderer(rawOptions);
+        if (propertyRenderer != null) {
+            return propertyRenderer;
+        }
+
+        // Create a stack trace renderer
+        final ThrowableStackTraceRendererFactory effectiveStackTraceRendererFactory = stackTraceRendererFactory != null
+                ? stackTraceRendererFactory
+                : ThrowableStackTraceRendererFactory.INSTANCE;
+        return effectiveStackTraceRendererFactory.createStackTraceRenderer(options);
     }
 
     /**
diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowablePropertyRenderer.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowablePropertyRenderer.java
deleted file mode 100644
index 62fce22e67..0000000000
--- a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowablePropertyRenderer.java
+++ /dev/null
@@ -1,98 +0,0 @@
-/*
- * 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.logging.log4j.core.pattern;
-
-import org.apache.logging.log4j.core.impl.ThrowableFormatOptions;
-import org.jspecify.annotations.NullMarked;
-import org.jspecify.annotations.Nullable;
-
-@NullMarked
-enum ThrowablePropertyRenderer implements ThrowableRenderer {
-    MESSAGE(ThrowableFormatOptions.MESSAGE, (buffer, throwable, lineSeparator) -> {
-        final String message = throwable.getMessage();
-        buffer.append(message);
-        buffer.append(lineSeparator);
-    }),
-    LOCALIZED_MESSAGE(ThrowableFormatOptions.LOCALIZED_MESSAGE, (buffer, throwable, lineSeparator) -> {
-        final String localizedMessage = throwable.getLocalizedMessage();
-        buffer.append(localizedMessage);
-        buffer.append(lineSeparator);
-    }),
-    CLASS_NAME(ThrowableFormatOptions.CLASS_NAME, ((buffer, throwable, lineSeparator) -> {
-        @Nullable final StackTraceElement[] stackTraceElements = throwable.getStackTrace();
-        if (stackTraceElements != null && stackTraceElements.length > 0) {
-            final StackTraceElement throwingMethod = stackTraceElements[0];
-            final String className = throwingMethod.getClassName();
-            buffer.append(className);
-            buffer.append(lineSeparator);
-        }
-    })),
-    METHOD_NAME(ThrowableFormatOptions.METHOD_NAME, ((buffer, throwable, lineSeparator) -> {
-        @Nullable final StackTraceElement[] stackTraceElements = throwable.getStackTrace();
-        if (stackTraceElements != null && stackTraceElements.length > 0) {
-            final StackTraceElement throwingMethod = stackTraceElements[0];
-            final String methodName = throwingMethod.getMethodName();
-            buffer.append(methodName);
-            buffer.append(lineSeparator);
-        }
-    })),
-    LINE_NUMBER(ThrowableFormatOptions.LINE_NUMBER, ((buffer, throwable, lineSeparator) -> {
-        @Nullable final StackTraceElement[] stackTraceElements = throwable.getStackTrace();
-        if (stackTraceElements != null && stackTraceElements.length > 0) {
-            final StackTraceElement throwingMethod = stackTraceElements[0];
-            final int lineNumber = throwingMethod.getLineNumber();
-            buffer.append(lineNumber);
-            buffer.append(lineSeparator);
-        }
-    })),
-    FILE_NAME(ThrowableFormatOptions.FILE_NAME, ((buffer, throwable, lineSeparator) -> {
-        @Nullable final StackTraceElement[] stackTraceElements = throwable.getStackTrace();
-        if (stackTraceElements != null && stackTraceElements.length > 0) {
-            final StackTraceElement throwingMethod = stackTraceElements[0];
-            final String fileName = throwingMethod.getFileName();
-            buffer.append(fileName);
-            buffer.append(lineSeparator);
-        }
-    }));
-
-    private final String name;
-
-    private final ThrowableRenderer delegate;
-
-    ThrowablePropertyRenderer(final String name, final ThrowableRenderer delegate) {
-        this.name = name;
-        this.delegate = delegate;
-    }
-
-    @Override
-    public void renderThrowable(final StringBuilder buffer, final Throwable throwable, final String lineSeparator) {
-        delegate.renderThrowable(buffer, throwable, lineSeparator);
-    }
-
-    @Nullable
-    static ThrowablePropertyRenderer fromOptions(@Nullable final String[] options) {
-        if (options != null && options.length > 0) {
-            final String name = options[0];
-            for (final ThrowablePropertyRenderer renderer : values()) {
-                if (renderer.name.equalsIgnoreCase(name)) {
-                    return renderer;
-                }
-            }
-        }
-        return null;
-    }
-}
diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowablePropertyRendererFactory.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowablePropertyRendererFactory.java
new file mode 100644
index 0000000000..c0fc761117
--- /dev/null
+++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowablePropertyRendererFactory.java
@@ -0,0 +1,120 @@
+/*
+ * 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.logging.log4j.core.pattern;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.function.Function;
+import org.apache.logging.log4j.core.impl.ThrowableFormatOptions;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * A factory of {@link ThrowableRenderer} implementations for extracting certain properties from a {@link Throwable}.
+ */
+@NullMarked
+class ThrowablePropertyRendererFactory {
+
+    private static final ThrowableRenderer MESSAGE_RENDERER = (buffer, throwable, lineSeparator) -> {
+        final String message = throwable.getMessage();
+        buffer.append(message);
+    };
+
+    private static final ThrowableRenderer LOCALIZED_MESSAGE_RENDERER = (buffer, throwable, lineSeparator) -> {
+        final String localizedMessage = throwable.getLocalizedMessage();
+        buffer.append(localizedMessage);
+    };
+
+    private static final Function<Throwable, @Nullable StackTraceElement> THROWING_METHOD_EXTRACTOR = throwable -> {
+        @Nullable final StackTraceElement[] stackTraceElements = throwable.getStackTrace();
+        return (stackTraceElements != null && stackTraceElements.length > 0) ? stackTraceElements[0] : null;
+    };
+
+    static final ThrowablePropertyRendererFactory INSTANCE =
+            new ThrowablePropertyRendererFactory(THROWING_METHOD_EXTRACTOR);
+
+    private final Map<String, ThrowableRenderer> rendererByPropertyName;
+
+    ThrowablePropertyRendererFactory(final Function<Throwable, @Nullable StackTraceElement> throwingMethodExtractor) {
+        this.rendererByPropertyName = createRendererByPropertyName(throwingMethodExtractor);
+    }
+
+    private static Map<String, ThrowableRenderer> createRendererByPropertyName(
+            final Function<Throwable, @Nullable StackTraceElement> throwingMethodExtractor) {
+        final Map<String, ThrowableRenderer> map = new HashMap<>();
+        map.put(ThrowableFormatOptions.MESSAGE, MESSAGE_RENDERER);
+        map.put(ThrowableFormatOptions.LOCALIZED_MESSAGE, LOCALIZED_MESSAGE_RENDERER);
+        map.put(ThrowableFormatOptions.CLASS_NAME, createClassNameRenderer(throwingMethodExtractor));
+        map.put(ThrowableFormatOptions.METHOD_NAME, createMethodNameRenderer(throwingMethodExtractor));
+        map.put(ThrowableFormatOptions.LINE_NUMBER, createLineNumberRenderer(throwingMethodExtractor));
+        map.put(ThrowableFormatOptions.FILE_NAME, createFileNameRenderer(throwingMethodExtractor));
+        return map;
+    }
+
+    private static ThrowableRenderer createClassNameRenderer(
+            final Function<Throwable, @Nullable StackTraceElement> throwingMethodExtractor) {
+        return (buffer, throwable, lineSeparator) -> {
+            @Nullable final StackTraceElement throwingMethod = throwingMethodExtractor.apply(throwable);
+            if (throwingMethod != null) {
+                final String className = throwingMethod.getClassName();
+                buffer.append(className);
+            }
+        };
+    }
+
+    private static ThrowableRenderer createMethodNameRenderer(
+            final Function<Throwable, @Nullable StackTraceElement> throwingMethodExtractor) {
+        return (buffer, throwable, lineSeparator) -> {
+            @Nullable final StackTraceElement throwingMethod = throwingMethodExtractor.apply(throwable);
+            if (throwingMethod != null) {
+                final String methodName = throwingMethod.getMethodName();
+                buffer.append(methodName);
+            }
+        };
+    }
+
+    private static ThrowableRenderer createLineNumberRenderer(
+            final Function<Throwable, @Nullable StackTraceElement> throwingMethodExtractor) {
+        return (buffer, throwable, lineSeparator) -> {
+            @Nullable final StackTraceElement throwingMethod = throwingMethodExtractor.apply(throwable);
+            if (throwingMethod != null) {
+                final int lineNumber = throwingMethod.getLineNumber();
+                buffer.append(lineNumber);
+            }
+        };
+    }
+
+    private static ThrowableRenderer createFileNameRenderer(
+            final Function<Throwable, @Nullable StackTraceElement> throwingMethodExtractor) {
+        return (buffer, throwable, lineSeparator) -> {
+            @Nullable final StackTraceElement throwingMethod = throwingMethodExtractor.apply(throwable);
+            if (throwingMethod != null) {
+                final String fileName = throwingMethod.getFileName();
+                buffer.append(fileName);
+            }
+        };
+    }
+
+    @Nullable
+    final ThrowableRenderer createPropertyRenderer(@Nullable final String[] options) {
+        if (options != null && options.length > 0) {
+            final String propertyName = options[0];
+            return rendererByPropertyName.get(propertyName);
+        }
+        return null;
+    }
+}
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 b16e9b9836..a6211147a8 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,6 +16,8 @@
  */
 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;
@@ -53,6 +55,7 @@ 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) {
@@ -62,6 +65,13 @@ 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/ThrowableStackTraceRendererFactory.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowableStackTraceRendererFactory.java
new file mode 100644
index 0000000000..4e6fee7fcc
--- /dev/null
+++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowableStackTraceRendererFactory.java
@@ -0,0 +1,33 @@
+/*
+ * 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.logging.log4j.core.pattern;
+
+import org.apache.logging.log4j.core.impl.ThrowableFormatOptions;
+
+/**
+ * A {@link ThrowableStackTraceRenderer} factory contract.
+ */
+class ThrowableStackTraceRendererFactory {
+
+    static final ThrowableStackTraceRendererFactory INSTANCE = new ThrowableStackTraceRendererFactory();
+
+    ThrowableStackTraceRendererFactory() {}
+
+    ThrowableStackTraceRenderer<?> createStackTraceRenderer(final ThrowableFormatOptions options) {
+        return new ThrowableStackTraceRenderer<>(options.getIgnorePackages(), options.getLines());
+    }
+}
diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/util/Throwables.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/util/Throwables.java
index 226b0f10cb..7c97d495ba 100644
--- a/log4j-core/src/main/java/org/apache/logging/log4j/core/util/Throwables.java
+++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/util/Throwables.java
@@ -16,6 +16,8 @@
  */
 package org.apache.logging.log4j.core.util;
 
+import static java.util.Objects.requireNonNull;
+
 import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
 import java.io.IOException;
 import java.io.InterruptedIOException;
@@ -24,7 +26,9 @@ import java.io.PrintWriter;
 import java.io.StringReader;
 import java.io.StringWriter;
 import java.util.ArrayList;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
 
 /**
  * Helps with Throwable objects.
@@ -34,31 +38,22 @@ public final class Throwables {
     private Throwables() {}
 
     /**
-     * Returns the deepest cause of the given {@code throwable}.
+     * Extracts the deepest exception in the causal chain of the given {@code throwable}.
+     * Circular references will be handled and ignored.
      *
-     * @param throwable the throwable to navigate
-     * @return the deepest throwable or the given throwable
+     * @param throwable a throwable to navigate
+     * @return the deepest exception in the causal chain
      */
     public static Throwable getRootCause(final Throwable throwable) {
-
-        // Keep a second pointer that slowly walks the causal chain. If the fast
-        // pointer ever catches the slower pointer, then there's a loop.
-        Throwable slowPointer = throwable;
-        boolean advanceSlowPointer = false;
-
-        Throwable parent = throwable;
-        Throwable cause;
-        while ((cause = parent.getCause()) != null) {
-            parent = cause;
-            if (parent == slowPointer) {
-                throw new IllegalArgumentException("loop in causal chain");
-            }
-            if (advanceSlowPointer) {
-                slowPointer = slowPointer.getCause();
-            }
-            advanceSlowPointer = !advanceSlowPointer; // only advance every other iteration
+        requireNonNull(throwable, "throwable");
+        final Set<Throwable> visitedThrowables = new HashSet<>();
+        Throwable prevCause = throwable;
+        visitedThrowables.add(prevCause);
+        Throwable nextCause;
+        while ((nextCause = prevCause.getCause()) != null && visitedThrowables.add(nextCause)) {
+            prevCause = nextCause;
         }
-        return parent;
+        return prevCause;
     }
 
     /**
diff --git a/log4j-layout-template-json/src/main/java/org/apache/logging/log4j/layout/template/json/resolver/StackTraceStringResolver.java b/log4j-layout-template-json/src/main/java/org/apache/logging/log4j/layout/template/json/resolver/StackTraceStringResolver.java
index dddf66f727..5a3e727a9c 100644
--- a/log4j-layout-template-json/src/main/java/org/apache/logging/log4j/layout/template/json/resolver/StackTraceStringResolver.java
+++ b/log4j-layout-template-json/src/main/java/org/apache/logging/log4j/layout/template/json/resolver/StackTraceStringResolver.java
@@ -16,6 +16,8 @@
  */
 package org.apache.logging.log4j.layout.template.json.resolver;
 
+import static org.apache.logging.log4j.util.Strings.LINE_SEPARATOR;
+
 import java.util.List;
 import java.util.function.Consumer;
 import java.util.function.Supplier;
@@ -126,7 +128,7 @@ final class StackTraceStringResolver implements StackTraceResolver {
             final int truncationPointIndex = findTruncationPointIndex(srcWriter, startIndex, endIndex, sequencePointer);
             if (truncationPointIndex > 0) {
                 dstWriter.append(srcWriter, startIndex, truncationPointIndex);
-                dstWriter.append(System.lineSeparator());
+                dstWriter.append(LINE_SEPARATOR);
                 dstWriter.append(truncationSuffix);
             }
 
@@ -137,7 +139,7 @@ final class StackTraceStringResolver implements StackTraceResolver {
 
             // Copy the label to avoid stepping over it again.
             if (labeledLineStartIndex > 0) {
-                dstWriter.append(System.lineSeparator());
+                dstWriter.append(LINE_SEPARATOR);
                 startIndex = labeledLineStartIndex;
                 for (; ; ) {
                     final char c = srcWriter.charAt(startIndex++);
diff --git a/src/site/antora/modules/ROOT/pages/manual/pattern-layout.adoc b/src/site/antora/modules/ROOT/pages/manual/pattern-layout.adoc
index 57c29d892f..c754462477 100644
--- a/src/site/antora/modules/ROOT/pages/manual/pattern-layout.adoc
+++ b/src/site/antora/modules/ROOT/pages/manual/pattern-layout.adoc
@@ -142,7 +142,7 @@ xref:manual/lookups.adoc#event-context[event evaluation context].
 
 [WARNING]
 ====
-If the provided pattern does not contain an exception converter and <<plugin-attr-alwaysWriteExceptions>> is not disabled, an implicit <<converter-exception-extended,`%xEX`>> is appended to the pattern.
+If the provided pattern does not contain an exception converter and <<plugin-attr-alwaysWriteExceptions>> is not disabled, an implicit <<converter-exception-extended,`%xEx`>> is appended to the pattern.
 ====
 
 [#plugin-attr-patternSelector]
@@ -176,7 +176,7 @@ If configured, the `replace` element must specify the regular expression to matc
 |Default value |`true`
 |===
 
-If `true` and the user-provided pattern does not contain an exception converter, an implicit <<converter-exception-extended,`%xEX`>> pattern is appended.
+If `true` and the user-provided pattern does not contain an exception converter, an implicit <<converter-exception-extended,`%xEx`>> pattern is appended.
 This means that if you do not include a way to output exceptions in your pattern, the default exception formatter will be added to the end of the pattern.
 Setting this to `false` disables this behavior and allows you to exclude exceptions from your pattern output.
 
@@ -599,69 +599,76 @@ equals{pattern}{test}{substitution}
 equalsIgnoreCase{pattern}{test}{substitution}
 ----
 
-For example, `%equals{[%marker]}{[]}\{}` will replace `[]` strings produced by events without markers with an empty string.
+For example, `%equals{[%marker]}{[]}{}` will replace `[]` strings produced by events without markers with an empty string.
 
 The pattern can be arbitrarily complex and in particular can contain multiple conversion keywords.
 
 [#converter-exception]
 ==== Exception
 
-Outputs the `Throwable` attached to the log event
+Outputs information extracted from the `Throwable` attached to the log event.
+It features two modes:
 
-.link:../javadoc/log4j-core/org/apache/logging/log4j/core/pattern/ThrowablePatternConverter.html[`ThrowablePatternConverter`] specifier grammar
+. <<converter-exception-stack-trace,Rendering the exception stack trace>> (the default mode)
+. <<converter-exception-property,Extracting an exception property>> (message, class name, line number, etc.)
+
+[WARNING]
+====
+Exception converter is not garbage-free.
+====
+
+[#converter-exception-stack-trace]
+===== Exception stack trace
+
+In this mode, the exception stack trace will be rendered according to the configuration provided.
+
+[IMPORTANT]
+====
+All rendered exception stack traces are ensured to be prefixed with a new line obtained using `System.lineSeparator()`.
+====
+
+link:../javadoc/log4j-core/org/apache/logging/log4j/core/pattern/ThrowablePatternConverter.html[`ThrowablePatternConverter`] specifier grammar **for rendering stack traces**:
 [source,text]
 ----
 ex|exception|throwable
   { "none"
-  | "full"
-  | depth
   | "short"
-  | "short.className"
-  | "short.fileName"
-  | "short.lineNumber"
-  | "short.methodName"
-  | "short.message"
-  | "short.localizedMessage"
+  | depth
+  | "full"
   }
   {filters(package,package,...)}
+  {separator(text)}
   {suffix(pattern)}
-  {separator(separator)}
 ----
 
-By default this will output the full stack trace as one would normally find with a call to `Throwable#printStackTrace()`.
-
-You can follow the throwable conversion word with an option in the form `%throwable\{option}`.
-
-`%throwable\{short}` outputs the first line of the `Throwable`.
-
-`%throwable{short.className}` outputs the name of the class where the exception occurred.
-
-`%throwable{short.methodName}` outputs the name of the method where the exception occurred.
+If this mode is employed without any configuration, the output will be identical to the one obtained from `Throwable#printStackTrace()`.
 
-`%throwable{short.fileName}` outputs the name of the file containing the class where the exception occurred.
+`none`:: Suppress the output of the converter
 
-`%throwable{short.lineNumber}` outputs the line number of the file containing the class where the exception occurred.
+`short`:: Outputs the first line of the stack trace (analogous to `%ex\{1}`)
 
-`%throwable{short.message}` outputs the message.
+`depth`:: Outputs the first `depth` lines of the stack trace (`%ex\{0}` is analogous to `%ex\{none}`)
 
-`%throwable{short.localizedMessage}` outputs the localized message.
+`full`:: Outputs the complete stack trace (analogous to no configuration)
 
-`%throwable\{n}` outputs the first `n` lines of the stack trace.
-
-Specifying `%throwable\{none}` or `%throwable\{0}` suppresses output of the exception.
-
-Use `{filters(packages)}`, where `packages` is a list of package names, to suppress matching stack frames from stack traces.
+`filters(package,package,...)`:: Suppresses stack trace elements of classes located in packages whose names start with the package names provided.
+Suppressed stack trace elements will be denoted in the output.
+For instance, `%ex{filters(org.junit)}` can be used to suppress JUnit classes in the rendered stack trace.
 
+`separator(text)`::
+`suffix(pattern)`::
++
+--
 You can change the used line separator in multiple ways:
 
-* Use `{separator(separator)}` to set the separator string literal.
+* Use `separator(text)` to set the separator string literal.
 It defaults to `System.lineSeparator()`.
-The contents of `separator` will be rendered verbatim without being subject to any processing.
+The contents of `text` will be rendered verbatim without being subject to any processing.
 
-* `{suffix(pattern)}` is identical to `{separator(separator)}` with the exception that the provided `pattern` will be processed as a xref:manual/pattern-layout.adoc[] conversion pattern before being rendered.
+* `suffix(pattern)` is identical to `{separator(text)}` with the exception that the provided `pattern` will be processed as a xref:manual/pattern-layout.adoc[] conversion pattern before being rendered.
 Exception-rendering directives in the `pattern` (`%ex`, `%rEx`, etc.) will be discarded.
 
-`{separator(...)}` and `{suffix(pattern)}` get concatenated to produce _the effective line separator_ as follows:
+`{separator(text)}` and `{suffix(pattern)}` get concatenated to produce _the effective line separator_ as follows:
 
 [source,java]
 ----
@@ -673,38 +680,52 @@ String effectiveLineSeparator(String separator, String suffix, LogEvent event) {
 }
 ----
 
-[WARNING]
+[TIP]
 ====
-Exception converter is not garbage-free.
+You are strongly advised to avoid using both `separator(text)` and `suffix(pattern)` at the same time; simply use one instead.
 ====
+--
 
-[#converter-exception-extended]
-==== Exception (Extended)
+[#converter-exception-property]
+===== Exception property
 
-The same as <<converter-exception,the `exception` converter>>, but also includes class packaging information
+In this mode, extracted attributes of the `Throwable` are injected _verbatim_.
+That is, no newlines, suffixes, prefixes, etc. will be added.
 
-.link:../javadoc/log4j-core/org/apache/logging/log4j/core/pattern/ThrowablePatternConverter.html[`ThrowablePatternConverter`] specifier grammar
+link:../javadoc/log4j-core/org/apache/logging/log4j/core/pattern/ThrowablePatternConverter.html[`ThrowablePatternConverter`] specifier grammar **for extracting properties**:
 [source,text]
 ----
-xEx|xException|xThrowable
-  { "none"
-  | "full"
-  | depth
-  | "short"
-  | "short.className"
+ex|exception|throwable
+  { "short.className"
   | "short.fileName"
   | "short.lineNumber"
   | "short.methodName"
   | "short.message"
   | "short.localizedMessage"
   }
-  {filters(package,package,...)}
-  {suffix(pattern)}
-  {separator(separator)}
 ----
 
-Different from <<converter-exception,the `%throwable` conversion>>, at the end of each stack element of the exception, a string containing the name of the JAR file that contains the class or the directory the class is located in and the `Implementation-Version` as found in that JAR's manifest will be added.
-If the information is uncertain, then the class packaging data will be preceded by a `~` (tilde) character.
+`short.className`:: Class name of the first stack trace element in the causal chain
+`short.fileName`:: File name of the first stack trace element in the causal chain
+`short.lineNumber`:: Line number of the first stack trace element in the causal chain
+`short.methodName`:: Method name of the first stack trace element in the causal chain
+`short.message`:: Exception message
+`short.message`:: Localized exception message
+
+[#converter-exception-extended]
+==== Exception (Extended)
+
+The same as <<converter-exception,the `exception` converter>>, but additionally includes class packaging information in the rendered stack traces.
+
+.link:../javadoc/log4j-core/org/apache/logging/log4j/core/pattern/ExtendedThrowablePatternConverter.html[`ExtendedThrowablePatternConverter`] specifier grammar
+[source,text]
+----
+xEx|xException|xThrowable
+  [... same as the exception converter grammar ...]
+----
+
+Each stack trace element is suffixed with a string containing the name of the JAR file that contains the class (or the directory the class is located in) and the `Implementation-Version` as found in that JAR's manifest.
+If the information is uncertain, then the class packaging information will be preceded by a `~` (tilde) character.
 
 [#converter-file]
 ==== File
@@ -1194,28 +1215,21 @@ For instance, `%replace{%logger %msg}{\.}{/}` will replace all dots in the logge
 [#converter-rootException]
 ==== Root exception
 
-The same as <<converter-exception,the `exception` converter>>, but the stack trace is printed starting with the first exception in the causal chain that was thrown followed by each subsequent wrapping exception
+Same as <<converter-exception,the `exception` converter>>, but the stack trace causal chain is processed in reverse order.
 
 .link:../javadoc/log4j-core/org/apache/logging/log4j/core/pattern/RootThrowablePatternConverter.html[`RootThrowablePatternConverter`] specifier grammar
 [source,text]
 ----
 rEx|rException|rThrowable
-  { "none"
-  | "full"
-  | depth
-  | "short"
-  | "short.className"
-  | "short.fileName"
-  | "short.lineNumber"
-  | "short.methodName"
-  | "short.message"
-  | "short.localizedMessage"
-  }
-  {filters(package,package,...)}
-  {suffix(pattern)}
-  {separator(separator)}
+  [... same as the exception converter grammar ...]
 ----
 
+[IMPORTANT]
+====
+Note that the inverted causal chain will not only affect the stack trace, but also extracted properties.
+That is, for instance, `%rEx{short.className}` and `%ex{short.className}` might yield different results.
+====
+
 [#converter-seq]
 ==== Sequence number