Skip to content

Commit 4ed5a1e

Browse files
ppkarwaszvy
andcommitted
fix: make %ex behavior context-independent (#3919)
Historically, throwable pattern converters (`%ex`, `%xEx`, etc.) behaved in a context-sensitive way: * If the **preceding formatter’s expansion** did not end with whitespace, the converter automatically inserted a space before rendering the exception. In version `2.25.0`, this was changed to insert a **newline** instead of a space, but the behavior was still dependent on surrounding context. #### What this change does This PR removes the context-dependent behavior altogether and makes `%ex` expansion fully predictable, while remaining backward-compatible: * When `%ex` is **added implicitly** because `alwaysWriteExceptions=true`: * If the pattern already ends with `%n`, a plain `%ex` is appended. * Otherwise, `%notEmpty{%n%ex}` is appended. This ensures exceptions are always clearly separated from the main log message by a newline, without adding extra characters when no exception is present. * When `%ex` is **explicitly included** in the pattern by the user: * Its expansion is rendered exactly as written in the pattern. * It will **never** prepend a newline on its own. #### Why * Eliminates confusing context-sensitive behavior. * Makes output consistent and predictable. * Preserves legacy expectations by only modifying implicitly added `%ex`. Closes #3873 Co-authored-by: Volkan Yazıcı <volkan@yazi.ci>
1 parent 7268f03 commit 4ed5a1e

File tree

12 files changed

+117
-49
lines changed

12 files changed

+117
-49
lines changed

log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/PatternParserTest.java

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@
1616
*/
1717
package org.apache.logging.log4j.core.pattern;
1818

19+
import static org.assertj.core.api.Assertions.assertThat;
1920
import static org.junit.jupiter.api.Assertions.assertEquals;
2021
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
2122
import static org.junit.jupiter.api.Assertions.assertNotNull;
2223
import static org.junit.jupiter.api.Assertions.assertTrue;
2324

2425
import java.util.Calendar;
2526
import java.util.List;
27+
import java.util.stream.Stream;
2628
import org.apache.logging.log4j.Level;
2729
import org.apache.logging.log4j.MarkerManager;
2830
import org.apache.logging.log4j.core.LogEvent;
@@ -40,6 +42,8 @@
4042
import org.apache.logging.log4j.util.Strings;
4143
import org.junit.jupiter.api.BeforeEach;
4244
import org.junit.jupiter.api.Test;
45+
import org.junit.jupiter.params.ParameterizedTest;
46+
import org.junit.jupiter.params.provider.MethodSource;
4347

4448
class PatternParserTest {
4549

@@ -98,6 +102,8 @@ void testCustomPattern() {
98102
assertNotNull(formatters);
99103
final StringMap mdc = ContextDataFactory.createContextData();
100104
mdc.putValue("loginId", "Fred");
105+
// The line number of the Throwable definition
106+
final int nextLineNumber = 107;
101107
final Throwable t = new Throwable();
102108
final StackTraceElement[] elements = t.getStackTrace();
103109
final Log4jLogEvent event = Log4jLogEvent.newBuilder() //
@@ -116,8 +122,9 @@ void testCustomPattern() {
116122
formatter.format(event, buf);
117123
}
118124
final String str = buf.toString();
119-
final String expected = "INFO [PatternParserTest :101 ] - Hello, world" + Strings.LINE_SEPARATOR;
120-
assertTrue(str.endsWith(expected), "Expected to end with: " + expected + ". Actual: " + str);
125+
final String expected =
126+
"INFO [PatternParserTest :" + nextLineNumber + " ] - Hello, world" + Strings.LINE_SEPARATOR;
127+
assertThat(str).endsWith(expected);
121128
}
122129

123130
@Test
@@ -369,6 +376,39 @@ void testClosingBracketButWrongPlace() {
369376
validateConverter(formatters, 1, "Date");
370377
}
371378

379+
static Stream<String> testAlwaysWriteExceptions_ensuresPrecededByNewline() {
380+
return Stream.of("", "%m", "%n", "%m%n");
381+
}
382+
383+
@ParameterizedTest
384+
@MethodSource
385+
void testAlwaysWriteExceptions_ensuresPrecededByNewline(final String pattern) {
386+
final List<PatternFormatter> formatters = parser.parse(pattern, true, false, false);
387+
assertNotNull(formatters);
388+
if (pattern.endsWith("%n")) {
389+
// Case 1: the original pattern ends with a new line, so the last converter is a ThrowablePatternConverter
390+
assertThat(formatters).hasSizeGreaterThan(1);
391+
final LogEventPatternConverter lastConverter =
392+
formatters.get(formatters.size() - 1).getConverter();
393+
assertThat(lastConverter).isInstanceOf(ThrowablePatternConverter.class);
394+
LogEventPatternConverter secondLastConverter =
395+
formatters.get(formatters.size() - 2).getConverter();
396+
assertThat(secondLastConverter).isInstanceOf(LineSeparatorPatternConverter.class);
397+
} else {
398+
// Case 2: the original pattern does not end with a new line, so we add a composite converter
399+
// that appends a new line and the exception if an exception is present.
400+
assertThat(formatters).hasSizeGreaterThan(0);
401+
final LogEventPatternConverter lastConverter =
402+
formatters.get(formatters.size() - 1).getConverter();
403+
assertThat(lastConverter).isInstanceOf(VariablesNotEmptyReplacementConverter.class);
404+
final List<PatternFormatter> nestedFormatters =
405+
((VariablesNotEmptyReplacementConverter) lastConverter).formatters;
406+
assertThat(nestedFormatters).hasSize(2);
407+
assertThat(nestedFormatters.get(0).getConverter()).isInstanceOf(LineSeparatorPatternConverter.class);
408+
assertThat(nestedFormatters.get(1).getConverter()).isInstanceOf(ThrowablePatternConverter.class);
409+
}
410+
}
411+
372412
@Test
373413
void testExceptionWithFilters() {
374414
final List<PatternFormatter> formatters =

log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/RootThrowablePatternConverterTest.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,11 +91,11 @@ class StackTraceTest extends AbstractStackTraceTest {
9191

9292
@Test
9393
@Override
94-
void output_should_be_newline_prefixed() {
94+
void output_should_not_be_newline_prefixed() {
9595
final String pattern = "%p" + patternPrefix;
9696
final String stackTrace = convert(pattern);
9797
final String expectedStart = String.format(
98-
"%s%n[CIRCULAR REFERENCE: %s", LEVEL, EXCEPTION.getClass().getCanonicalName());
98+
"%s[CIRCULAR REFERENCE: %s", LEVEL, EXCEPTION.getClass().getCanonicalName());
9999
assertThat(stackTrace).as("pattern=`%s`", pattern).startsWith(expectedStart);
100100
}
101101

log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/ThrowablePatternConverterTest.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import org.junit.jupiter.api.Test;
3838
import org.junit.jupiter.params.ParameterizedTest;
3939
import org.junit.jupiter.params.provider.MethodSource;
40+
import org.junitpioneer.jupiter.Issue;
4041

4142
/**
4243
* {@link ThrowablePatternConverter} tests.
@@ -384,11 +385,12 @@ abstract static class AbstractStackTraceTest {
384385
}
385386

386387
@Test
387-
void output_should_be_newline_prefixed() {
388+
@Issue("https://github.com/apache/logging-log4j2/issues/3873")
389+
void output_should_not_be_newline_prefixed() {
388390
final String pattern = "%p" + patternPrefix;
389391
final String stackTrace = convert(pattern);
390392
final String expectedStart =
391-
String.format("%s%n%s", LEVEL, EXCEPTION.getClass().getCanonicalName());
393+
String.format("%s%s", LEVEL, EXCEPTION.getClass().getCanonicalName());
392394
assertThat(stackTrace).as("pattern=`%s`", pattern).startsWith(expectedStart);
393395
}
394396

log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/PatternParser.java

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,26 @@ public List<PatternFormatter> parse(
212212
list.add(new PatternFormatter(pc, field));
213213
}
214214
if (alwaysWriteExceptions && !handlesThrowable) {
215-
final LogEventPatternConverter pc = ThrowablePatternConverter.newInstance(config, new String[0]);
215+
// We need to guarantee that an exception is always written,
216+
// and that it is cleanly separated from the main log line by a newline.
217+
final LogEventPatternConverter pc;
218+
// Look at the last converter in the existing pattern.
219+
final PatternFormatter lastFormatter = list.isEmpty() ? null : list.get(list.size() - 1);
220+
221+
if (lastFormatter == null || !(lastFormatter.getConverter() instanceof LineSeparatorPatternConverter)) {
222+
// Case 1: The pattern does NOT already end with a newline.
223+
// In this case, we append a composite converter `%notEmpty{%n%ex}`.
224+
// - If no exception is present, it renders nothing (so the pattern behaves exactly as before).
225+
// - If an exception is present, it renders a newline followed by the stack trace.
226+
pc = VariablesNotEmptyReplacementConverter.newInstance(config, new String[] {"%n%ex"});
227+
} else {
228+
// Case 2: The pattern already ends with a newline.
229+
// In this case, we only need to add `%ex`:
230+
// - If no exception is present, nothing changes.
231+
// - If an exception is present, it is written immediately after the newline already in the pattern.
232+
pc = ThrowablePatternConverter.newInstance(config, Strings.EMPTY_ARRAY);
233+
}
234+
// Finally, add the chosen converter to the end of the pattern.
216235
list.add(new PatternFormatter(pc, FormattingInfo.getDefault()));
217236
}
218237
return list;

log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowableStackTraceRenderer.java

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@
1616
*/
1717
package org.apache.logging.log4j.core.pattern;
1818

19-
import static org.apache.logging.log4j.util.Strings.LINE_SEPARATOR;
20-
2119
import java.util.HashMap;
2220
import java.util.HashSet;
2321
import java.util.List;
@@ -55,7 +53,6 @@ public final void renderThrowable(
5553
if (maxLineCount > 0) {
5654
try {
5755
C context = createContext(throwable);
58-
ensureNewlineSuffix(buffer);
5956
renderThrowable(buffer, throwable, context, new HashSet<>(), lineSeparator);
6057
} catch (final Exception error) {
6158
if (error != MAX_LINE_COUNT_EXCEEDED) {
@@ -65,13 +62,6 @@ public final void renderThrowable(
6562
}
6663
}
6764

68-
private static void ensureNewlineSuffix(final StringBuilder buffer) {
69-
final int bufferLength = buffer.length();
70-
if (bufferLength > 0 && buffer.charAt(bufferLength - 1) != '\n') {
71-
buffer.append(LINE_SEPARATOR);
72-
}
73-
}
74-
7565
@SuppressWarnings("unchecked")
7666
C createContext(final Throwable throwable) {
7767
final Map<Throwable, Context.Metadata> metadataByThrowable = Context.Metadata.ofThrowable(throwable);

log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/VariablesNotEmptyReplacementConverter.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@
3535
@PerformanceSensitive("allocation")
3636
public final class VariablesNotEmptyReplacementConverter extends LogEventPatternConverter {
3737

38-
private final List<PatternFormatter> formatters;
38+
// package private for testing
39+
final List<PatternFormatter> formatters;
3940

4041
/**
4142
* Constructs the converter.

log4j-taglib/src/test/java/org/apache/logging/log4j/taglib/CatchingTagTest.java

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ public void testDoEndTag() throws Exception {
5757
this.tag.setException(new Exception("This is a test."));
5858

5959
assertEquals(Tag.EVAL_PAGE, this.tag.doEndTag(), "The return value is not correct.");
60-
verify("Catching ERROR M-CATCHING[ EXCEPTION ] E" + LINE_SEPARATOR + "java.lang.Exception: This is a test.");
60+
verify("Catching ERROR M-CATCHING[ EXCEPTION ] E-java.lang.Exception: This is a test.");
6161
}
6262

6363
@Test
@@ -66,8 +66,7 @@ public void testDoEndTagLevelString() throws Exception {
6666
this.tag.setException(new RuntimeException("This is another test."));
6767

6868
assertEquals(Tag.EVAL_PAGE, this.tag.doEndTag(), "The return value is not correct.");
69-
verify("Catching INFO M-CATCHING[ EXCEPTION ] E" + LINE_SEPARATOR
70-
+ "java.lang.RuntimeException: This is another test.");
69+
verify("Catching INFO M-CATCHING[ EXCEPTION ] E-java.lang.RuntimeException: This is another test.");
7170
}
7271

7372
@Test
@@ -76,7 +75,7 @@ public void testDoEndTagLevelObject() throws Exception {
7675
this.tag.setException(new Error("This is the last test."));
7776

7877
assertEquals(Tag.EVAL_PAGE, this.tag.doEndTag(), "The return value is not correct.");
79-
verify("Catching WARN M-CATCHING[ EXCEPTION ] E" + LINE_SEPARATOR + "java.lang.Error: This is the last test.");
78+
verify("Catching WARN M-CATCHING[ EXCEPTION ] E-java.lang.Error: This is the last test.");
8079
}
8180

8281
private void verify(final String expected) {

log4j-taglib/src/test/java/org/apache/logging/log4j/taglib/EnterTagTest.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ public void setUp() {
5454
@Test
5555
public void testDoEndTag() throws Exception {
5656
assertEquals(Tag.EVAL_PAGE, this.tag.doEndTag(), "The return value is not correct.");
57-
verify("Enter TRACE M-ENTER[ FLOW ] E");
57+
verify("Enter TRACE M-ENTER[ FLOW ] E-");
5858
}
5959

6060
@Test
@@ -63,7 +63,7 @@ public void testDoEndTagAttributes() throws Exception {
6363
this.tag.setDynamicAttribute(null, null, 5792);
6464

6565
assertEquals(Tag.EVAL_PAGE, this.tag.doEndTag(), "The return value is not correct.");
66-
verify("Enter params(log4j-test1.xml, 5792) TRACE M-ENTER[ FLOW ] E");
66+
verify("Enter params(log4j-test1.xml, 5792) TRACE M-ENTER[ FLOW ] E-");
6767
}
6868

6969
private void verify(final String expected) {

log4j-taglib/src/test/java/org/apache/logging/log4j/taglib/ExitTagTest.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,23 +53,23 @@ public void setUp() {
5353
@Test
5454
public void testDoEndTag() throws Exception {
5555
assertEquals(Tag.EVAL_PAGE, this.tag.doEndTag(), "The return value is not correct.");
56-
verify("Exit TRACE M-EXIT[ FLOW ] E");
56+
verify("Exit TRACE M-EXIT[ FLOW ] E-");
5757
}
5858

5959
@Test
6060
public void testDoEndTagResult01() throws Exception {
6161
this.tag.setResult(CONFIG);
6262

6363
assertEquals(Tag.EVAL_PAGE, this.tag.doEndTag(), "The return value is not correct.");
64-
verify("Exit with(log4j-test1.xml) TRACE M-EXIT[ FLOW ] E");
64+
verify("Exit with(log4j-test1.xml) TRACE M-EXIT[ FLOW ] E-");
6565
}
6666

6767
@Test
6868
public void testDoEndTagResult02() throws Exception {
6969
this.tag.setResult(5792);
7070

7171
assertEquals(Tag.EVAL_PAGE, this.tag.doEndTag(), "The return value is not correct.");
72-
verify("Exit with(5792) TRACE M-EXIT[ FLOW ] E");
72+
verify("Exit with(5792) TRACE M-EXIT[ FLOW ] E-");
7373
}
7474

7575
private void verify(final String expected) {

0 commit comments

Comments
 (0)