diff --git a/README.md b/README.md index 3723b2d4..32c410bd 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 + + 100 + 1000 + +``` + +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. diff --git a/src/main/java/net/logstash/logback/stacktrace/ShortenedThrowableConverter.java b/src/main/java/net/logstash/logback/stacktrace/ShortenedThrowableConverter.java index d32c4109..ece057a7 100644 --- a/src/main/java/net/logstash/logback/stacktrace/ShortenedThrowableConverter.java +++ b/src/main/java/net/logstash/logback/stacktrace/ShortenedThrowableConverter.java @@ -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()}. @@ -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) { @@ -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. diff --git a/src/test/java/net/logstash/logback/stacktrace/ShortenedThrowableConverterTest.java b/src/test/java/net/logstash/logback/stacktrace/ShortenedThrowableConverterTest.java index 11bce986..281c14bc 100644 --- a/src/test/java/net/logstash/logback/stacktrace/ShortenedThrowableConverterTest.java +++ b/src/test/java/net/logstash/logback/stacktrace/ShortenedThrowableConverterTest.java @@ -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() {