Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ The structure of the output, and the data it contains, is fully configurable.
* [Truncate after Regex](#truncate-after-regex)
* [Exclude Frames per Regex](#exclude-frames-per-regex)
* [Omit Throwable Messages](#omit-throwable-messages)
* [Maximum Message Length per Throwable](#maximum-message-length-per-throwable)
* [Maximum Depth per Throwable](#maximum-depth-per-throwable)
* [Maximum Trace Size (bytes)](#maximum-trace-size-bytes)
* [Classname Shortening](#classname-shortening)
Expand Down Expand Up @@ -2239,6 +2240,26 @@ Caused by: java.lang.ArithmeticException:

This enables devs to still see the type of exception thrown and where it occurred, **without** exposing sensitive data.

### Maximum Message Length per Throwable

A large exception message can consume the entire `maxLength` budget and hide the stack frames.
Use `maxErrorMessageLength` to limit each throwable's message independently:

```xml
<throwableConverter class="net.logstash.logback.stacktrace.ShortenedThrowableConverter">
<maxErrorMessageLength>100</maxErrorMessageLength>
<maxLength>1000</maxLength>
</throwableConverter>
```

The limit is a positive number of Java string characters (UTF-16 code units), including the `...`
suffix used for truncated messages. Limits smaller than three use a shortened suffix.
Surrogate pairs are kept intact, so a truncated message can be shorter than the limit.
The limit applies separately to the outer exception, causes, and suppressed exceptions.
Class names, prefixes, and line separators are outside this message limit; `maxLength` still
bounds the complete trace and may truncate it. Messages are unlimited by default; set `-1`
to restore that behavior. The `OMIT_THROWABLE_MESSAGE` marker continues to omit messages.

### Maximum Depth per Throwable

The `maxDepthPerThrowable` property is used to limit the depth of each individual throwable nested inside the original exception, caused-bys and suppressed exceptions included. Beyond this limit, additional elements are omitted and a message indicating the number elements removed is added instead.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,11 @@ public class ShortenedThrowableConverter extends ThrowableHandlingConverter {
*/
private int maxLength = DEFAULT_MAX_LENGTH;

/**
* Maximum number of characters in each throwable message, including any ellipsis.
*/
private int maxErrorMessageLength = FULL_MAX_LENGTH;

/**
* Abbreviator used to shorten the classnames.
* Initialized during {@link #start()}.
Expand Down Expand Up @@ -695,9 +700,20 @@ private void appendFirstLine(StringBuilder builder, String prefix, int indent, I
builder.append("<#" + hash + "> ");
}
builder.append(abbreviator.abbreviate(throwableProxy.getClassName()))
.append(": ")
.append(getMessage(throwableProxy, event))
.append(getLineSeparator());
.append(": ");
String message = String.valueOf(getMessage(throwableProxy, event));
if (message.length() > maxErrorMessageLength) {
int suffixLength = Math.min(ELLIPSIS.length(), maxErrorMessageLength);
int end = maxErrorMessageLength - suffixLength;
if (end > 0 && Character.isHighSurrogate(message.charAt(end - 1))
&& Character.isLowSurrogate(message.charAt(end))) {
end--;
}
builder.append(message, 0, end).append(ELLIPSIS, 0, suffixLength);
} else {
builder.append(message);
}
builder.append(getLineSeparator());
}

private void indent(StringBuilder builder, int indent) {
Expand Down Expand Up @@ -798,6 +814,24 @@ public int getMaxLength() {
return maxLength;
}

/**
* Set a character limit for each throwable message, including the truncation ellipsis.
* Applies to causes and suppressed exceptions as well. The overall {@link #setMaxLength(int)}
* limit still applies to the complete trace. By default messages are unlimited.
*
* @param maxErrorMessageLength a positive limit, or {@code -1} for no limit
*/
public void setMaxErrorMessageLength(int maxErrorMessageLength) {
if (maxErrorMessageLength <= 0 && maxErrorMessageLength != -1) {
throw new IllegalArgumentException("maxErrorMessageLength must be > 0, or -1 to disable the feature");
}
this.maxErrorMessageLength = maxErrorMessageLength == -1 ? FULL_MAX_LENGTH : maxErrorMessageLength;
}

public int getMaxErrorMessageLength() {
return maxErrorMessageLength;
}


/**
* Control whether common frames should be omitted for nested throwables or not.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,72 @@ public void testMaxLength() {
.endsWith("..." + converter.getLineSeparator());
}
}

@Test
public void testMessageLimitPreservesFramesForAllThrowables() {
for (boolean rootFirst : Arrays.asList(false, true)) {
RuntimeException cause = new RuntimeException("cause-" + "x".repeat(1000));
RuntimeException outer = new RuntimeException("outer-" + "x".repeat(1000), cause);
RuntimeException suppressed = new RuntimeException("suppressed-" + "x".repeat(1000));
outer.addSuppressed(suppressed);
StackTraceElement[] frames = {new StackTraceElement("example.Service", "call", "Service.java", 12)};
cause.setStackTrace(frames);
outer.setStackTrace(frames);
suppressed.setStackTrace(frames);
ShortenedThrowableConverter converter = new ShortenedThrowableConverter();
converter.setMaxErrorMessageLength(14);
converter.setRootCauseFirst(rootFirst);
converter.setOmitCommonFrames(false);
converter.setMaxLength(2000);
converter.start();

assertThat(convert(converter, outer))
.contains("outer-xxxxx...", "cause-xxxxx...", "suppressed-...", "\tat ")
.doesNotContain("x".repeat(12));
}
}

@Test
public void testMessageLengthBoundaries() {
for (int limit : Arrays.asList(1, 2, 3, 4, 5, 6)) {
ShortenedThrowableConverter converter = new ShortenedThrowableConverter();
converter.setMaxErrorMessageLength(limit);
converter.start();
String expected = limit >= 6 ? "abcdef" : "abcdef".substring(0, Math.max(0, limit - 3))
+ "...".substring(0, Math.min(3, limit));
assertThat(getLines(convert(converter, new RuntimeException("abcdef"))).get(0))
.isEqualTo("java.lang.RuntimeException: " + expected);
}
}

@Test
public void testUnlimitedNullEmptyAndOmittedMessages() {
ShortenedThrowableConverter converter = new ShortenedThrowableConverter();
converter.start();
String message = "x".repeat(2000);
assertThat(convert(converter, new RuntimeException(message))).contains(message);
converter.setMaxErrorMessageLength(5);
assertThat(convert(converter, new RuntimeException((String) null))).contains("RuntimeException: null");
assertThat(getLines(convert(converter, new RuntimeException(""))).get(0))
.isEqualTo("java.lang.RuntimeException: ");
ILoggingEvent event = createEvent(new RuntimeException(message));
when(event.getMarkerList()).thenReturn(List.of(ShortenedThrowableConverter.OMIT_THROWABLE_MESSAGE));
assertThat(getLines(convert(converter, event)).get(0)).isEqualTo("java.lang.RuntimeException: ");
converter.setMaxErrorMessageLength(-1);
assertThat(convert(converter, new RuntimeException(message))).contains(message);
assertThat(converter.getMaxErrorMessageLength()).isEqualTo(Integer.MAX_VALUE);
assertThatThrownBy(() -> converter.setMaxErrorMessageLength(0)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> converter.setMaxErrorMessageLength(-2)).isInstanceOf(IllegalArgumentException.class);
}

@Test
public void testMessageLimitDoesNotSplitSurrogatePair() {
ShortenedThrowableConverter converter = new ShortenedThrowableConverter();
converter.setMaxErrorMessageLength(5);
converter.start();
assertThat(getLines(convert(converter, new RuntimeException("a\uD83D\uDE00bcdef"))).get(0))
.isEqualTo("java.lang.RuntimeException: a...");
}

@Test
public void testMaxLengthShorterThanTruncationSuffix() {
Expand Down