Skip to content

Commit 775b570

Browse files
ValentinZakharovdevflow.devflow-routing-intake
andauthored
Fix DDSpan.addThrowable crashing when exception getMessage() throws (#11428)
Fix DDSpan.addThrowable crashing when exception getMessage() throws Add Javadoc to safeGetMessage and getStackTrace Protect getStackTrace() fallback against getStackTrace() also throwing Fix spotless formatting in StackTraces Javadoc Co-authored-by: devflow.devflow-routing-intake <devflow.devflow-routing-intake@kubernetes.us1.ddbuild.io>
1 parent 7270bfd commit 775b570

5 files changed

Lines changed: 144 additions & 5 deletions

File tree

dd-trace-core/src/main/java/datadog/trace/core/DDSpan.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -354,10 +354,10 @@ public DDSpan addThrowable(final Throwable error) {
354354
@Override
355355
public DDSpan addThrowable(Throwable error, byte errorPriority) {
356356
if (null != error) {
357-
String message = error.getMessage();
357+
String message = StackTraces.safeGetMessage(error);
358358
if (!"broken pipe".equalsIgnoreCase(message)
359359
&& (error.getCause() == null
360-
|| !"broken pipe".equalsIgnoreCase(error.getCause().getMessage()))) {
360+
|| !"broken pipe".equalsIgnoreCase(StackTraces.safeGetMessage(error.getCause())))) {
361361
// broken pipes happen when clients abort connections,
362362
// which might happen because the application is overloaded
363363
// or warming up - capturing the stack trace and keeping

dd-trace-core/src/main/java/datadog/trace/core/util/StackTraces.java

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import java.io.StringReader;
66
import java.io.StringWriter;
77
import java.util.ArrayList;
8+
import java.util.Arrays;
89
import java.util.List;
910
import java.util.regex.Matcher;
1011
import java.util.regex.Pattern;
@@ -13,10 +14,66 @@
1314
public final class StackTraces {
1415
private StackTraces() {}
1516

17+
/**
18+
* Safely retrieves the message from a throwable.
19+
*
20+
* <p>Third-party exception classes occasionally use formatting utilities (e.g. {@code
21+
* java.text.MessageFormat}) inside {@code getMessage()}, which can throw when the pattern
22+
* contains non-integer placeholders.
23+
*
24+
* @param t the throwable to retrieve the message from
25+
* @return {@code null} if {@code t} is {@code null}; the result of {@link Throwable#getMessage()}
26+
* on success; or a diagnostic string of the form {@code "(Exception message unavailable for
27+
* ClassName: getMessage() threw ExceptionType)"} if {@code getMessage()} throws
28+
*/
29+
public static String safeGetMessage(Throwable t) {
30+
if (t == null) {
31+
return null;
32+
}
33+
try {
34+
return t.getMessage();
35+
} catch (Exception e) {
36+
return "(Exception message unavailable for "
37+
+ t.getClass().getSimpleName()
38+
+ ": getMessage() threw "
39+
+ e.getClass().getSimpleName()
40+
+ ")";
41+
}
42+
}
43+
44+
/**
45+
* Returns the stack trace of {@code t} as a string, truncated to {@code maxChars} characters.
46+
*
47+
* <p>Uses {@link Throwable#printStackTrace(java.io.PrintWriter)} to produce the full trace
48+
* including {@code Caused by} and {@code Suppressed} chains. If {@code printStackTrace} itself
49+
* throws (e.g. because {@link Throwable#getMessage()} throws inside {@code toString()}), falls
50+
* back to reconstructing the trace from {@link Throwable#getStackTrace()} so the call site
51+
* remains locatable.
52+
*
53+
* @param t the throwable to format
54+
* @param maxChars maximum length of the returned string
55+
* @return the stack trace string, truncated if necessary
56+
*/
1657
public static String getStackTrace(Throwable t, int maxChars) {
17-
StringWriter sw = new StringWriter();
18-
t.printStackTrace(new PrintWriter(sw));
19-
String trace = sw.toString();
58+
String trace;
59+
try {
60+
StringWriter sw = new StringWriter();
61+
t.printStackTrace(new PrintWriter(sw));
62+
trace = sw.toString();
63+
} catch (Exception ignored) {
64+
// printStackTrace() failed (e.g. getMessage() throws inside toString()).
65+
// Reconstruct from getStackTrace() so the call site is still locatable.
66+
try {
67+
trace =
68+
t.getClass().getName()
69+
+ System.lineSeparator()
70+
+ Arrays.stream(t.getStackTrace())
71+
.map(f -> "\tat " + f)
72+
.collect(Collectors.joining(System.lineSeparator()));
73+
} catch (Exception ignored2) {
74+
trace = t.getClass().getName();
75+
}
76+
}
2077
try {
2178
return truncate(trace, maxChars);
2279
} catch (Exception e) {
@@ -43,6 +100,9 @@ static String truncate(String trace, int maxChars) {
43100
/* last-ditch centre cut to guarantee the limit */
44101
String cutMessage = "\t... trace centre-cut to " + maxChars + " chars ...";
45102
int retainedLength = maxChars - cutMessage.length() - 2; // 2 for the newlines
103+
if (retainedLength <= 0) {
104+
return cutMessage + System.lineSeparator();
105+
}
46106
int half = retainedLength / 2;
47107
return trace.substring(0, half)
48108
+ System.lineSeparator()

dd-trace-core/src/test/java/datadog/trace/core/DDSpanTest.java

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import static datadog.trace.core.DDSpanContext.SPAN_SAMPLING_RULE_RATE_TAG;
1111
import static org.junit.jupiter.api.Assertions.assertEquals;
1212
import static org.junit.jupiter.api.Assertions.assertFalse;
13+
import static org.junit.jupiter.api.Assertions.assertNotNull;
1314
import static org.junit.jupiter.api.Assertions.assertNull;
1415
import static org.junit.jupiter.api.Assertions.assertTrue;
1516
import static org.junit.jupiter.params.provider.Arguments.arguments;
@@ -33,6 +34,7 @@
3334
import datadog.trace.common.writer.ListWriter;
3435
import datadog.trace.core.propagation.ExtractedContext;
3536
import datadog.trace.core.propagation.PropagationTags;
37+
import datadog.trace.core.util.TestThrowables;
3638
import java.io.Closeable;
3739
import java.io.IOException;
3840
import java.lang.reflect.Field;
@@ -447,6 +449,39 @@ void nullExceptionSafeToAdd() {
447449
assertNull(span.getTag(DDTags.ERROR_STACK));
448450
}
449451

452+
@Test
453+
void addThrowableDoesNotFailWhenGetMessageThrows() {
454+
DDSpan span = (DDSpan) tracer.buildSpan("datadog", "root").start();
455+
456+
span.addThrowable(TestThrowables.throwingGetMessage());
457+
458+
assertTrue(span.isError());
459+
assertNotNull(span.getTag(DDTags.ERROR_TYPE));
460+
assertNotNull(
461+
span.getTag(DDTags.ERROR_STACK),
462+
"stack trace must be captured even when getMessage() throws");
463+
assertTrue(((String) span.getTag(DDTags.ERROR_MSG)).contains("Exception message unavailable"));
464+
}
465+
466+
@Test
467+
void addThrowableDoesNotFailWhenGetCauseMessageThrows() {
468+
// Outer exception has a normal message, so the broken-pipe check reaches
469+
// getCause().getMessage()
470+
RuntimeException error =
471+
new RuntimeException("outer message", TestThrowables.throwingGetMessage());
472+
DDSpan span = (DDSpan) tracer.buildSpan("datadog", "root").start();
473+
474+
span.addThrowable(error);
475+
476+
assertTrue(span.isError());
477+
assertNotNull(span.getTag(DDTags.ERROR_TYPE));
478+
assertNotNull(
479+
span.getTag(DDTags.ERROR_STACK),
480+
"stack trace must be captured even when cause's getMessage() throws");
481+
// Outer getMessage() works normally — message must be recorded
482+
assertEquals("outer message", span.getTag(DDTags.ERROR_MSG));
483+
}
484+
450485
@TableTest({
451486
"scenario | rate | limit ",
452487
"rate=1.0 lim=10 | 1.0 | 10 ",

dd-trace-core/src/test/java/datadog/trace/core/util/StackTracesTest.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package datadog.trace.core.util;
22

33
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertNull;
5+
import static org.junit.jupiter.api.Assertions.assertTrue;
46
import static org.junit.jupiter.params.provider.Arguments.arguments;
57

68
import java.util.stream.Stream;
9+
import org.junit.jupiter.api.Test;
710
import org.junit.jupiter.params.ParameterizedTest;
811
import org.junit.jupiter.params.provider.Arguments;
912
import org.junit.jupiter.params.provider.MethodSource;
@@ -64,6 +67,24 @@ class StackTracesTest {
6467
+ " at com.example.util.ResourceManager.close(ResourceManager.java:21)\n"
6568
+ " ... 3 more\n";
6669

70+
// --- safeGetMessage ---
71+
72+
@Test
73+
void safeGetMessageReturnsFallbackWhenGetMessageThrows() {
74+
String message = StackTraces.safeGetMessage(TestThrowables.throwingGetMessage());
75+
assertTrue(
76+
message.contains("Exception message unavailable"), "must indicate message is unavailable");
77+
assertTrue(
78+
message.contains("IllegalArgumentException"), "must include secondary exception type");
79+
}
80+
81+
@Test
82+
void safeGetMessageReturnsNullForNullInput() {
83+
assertNull(StackTraces.safeGetMessage(null));
84+
}
85+
86+
// --- getStackTrace with broken getMessage ---
87+
6788
@ParameterizedTest(name = "truncation limit {0}")
6889
@MethodSource("testTruncateArguments")
6990
void testTruncate(int limit, String expected) {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package datadog.trace.core.util;
2+
3+
import java.text.MessageFormat;
4+
5+
/** Test helpers for throwables with non-standard {@code getMessage()} behaviour. */
6+
public final class TestThrowables {
7+
private TestThrowables() {}
8+
9+
/**
10+
* Returns a {@link RuntimeException} whose {@link Throwable#getMessage()} throws {@link
11+
* IllegalArgumentException} via {@link MessageFormat} with non-integer placeholders — simulating
12+
* the third-party exception that triggered the production bug in {@code DDSpan.addThrowable}.
13+
*/
14+
public static RuntimeException throwingGetMessage() {
15+
return new RuntimeException() {
16+
@Override
17+
public String getMessage() {
18+
return MessageFormat.format(
19+
"Timeout after {TotalMilliseconds}ms matching pattern {Pattern}", "arg0", "arg1");
20+
}
21+
};
22+
}
23+
}

0 commit comments

Comments
 (0)