diff --git a/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-common/src/main/java/datadog/trace/instrumentation/servlet/dispatcher/RequestDispatcherInstrumentation.java b/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-common/src/main/java/datadog/trace/instrumentation/servlet/dispatcher/RequestDispatcherInstrumentation.java index 60cf8a49c95..8d1da631934 100644 --- a/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-common/src/main/java/datadog/trace/instrumentation/servlet/dispatcher/RequestDispatcherInstrumentation.java +++ b/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-common/src/main/java/datadog/trace/instrumentation/servlet/dispatcher/RequestDispatcherInstrumentation.java @@ -27,6 +27,7 @@ import datadog.context.ContextScope; import datadog.trace.agent.tooling.Instrumenter; import datadog.trace.agent.tooling.InstrumenterModule; +import datadog.trace.bootstrap.CallDepthThreadLocalMap; import datadog.trace.bootstrap.InstrumentationContext; import datadog.trace.bootstrap.instrumentation.api.AgentSpan; import datadog.trace.bootstrap.instrumentation.api.AgentSpanContext; @@ -100,6 +101,11 @@ public static ContextScope start( // Don't want to generate a new top-level span return null; } + + final int depth = CallDepthThreadLocalMap.incrementCallDepth(RequestDispatcher.class); + if (depth > 0) { + return null; + } final AgentSpanContext parent; if (servletSpan == null || (parentSpan != null && servletSpan.isSameTrace(parentSpan))) { // Use the parentSpan if the servletSpan is null or part of the same trace. @@ -147,6 +153,7 @@ public static void stop( if (scope == null) { return; } + CallDepthThreadLocalMap.reset(RequestDispatcher.class); if (requestContext != null) { request.setAttribute(DD_CONTEXT_ATTRIBUTE, requestContext); diff --git a/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-common/src/test/java/RequestDispatcherRecursionTest.java b/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-common/src/test/java/RequestDispatcherRecursionTest.java new file mode 100644 index 00000000000..ea9f498909e --- /dev/null +++ b/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-common/src/test/java/RequestDispatcherRecursionTest.java @@ -0,0 +1,195 @@ +import static datadog.trace.agent.test.utils.TraceUtils.runUnderTrace; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.trace.agent.test.AbstractInstrumentationTest; +import java.io.IOException; +import java.lang.reflect.Proxy; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import javax.servlet.RequestDispatcher; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletRequestWrapper; +import javax.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.Test; + +/** + * Regression test for the SAP Hybris/Spartacus StackOverflow reported in v1.63.0. + * + *
Root cause: {@code RequestDispatcherAdvice.start()} calls {@code injectContext()} which calls + * {@code request.setAttribute()} for each propagation header. In some request wrappers (Hybris + * internals, SAP Commerce), {@code setAttribute()} internally triggers another {@code + * RequestDispatcher.forward()} call. Without a re-entrancy guard the advice recurses until {@code + * StackOverflowError}, visible in production as many "Failed to handle exception in instrumentation + * for ApplicationDispatcher" log entries. + * + *
The fix is a {@code CallDepthThreadLocalMap} guard in {@code RequestDispatcherAdvice.start()}
+ * analogous to the one already present in {@code AsyncContextInstrumentation}.
+ */
+class RequestDispatcherRecursionTest extends AbstractInstrumentationTest {
+
+ /** Minimal {@code RequestDispatcher} stub — instrumented by ByteBuddy via interface match. */
+ static class TestDispatcher implements RequestDispatcher {
+ @Override
+ public void forward(ServletRequest req, ServletResponse resp)
+ throws ServletException, IOException {}
+
+ @Override
+ public void include(ServletRequest req, ServletResponse resp)
+ throws ServletException, IOException {}
+ }
+
+ /**
+ * {@code RequestDispatcher} with a non-trivial body simulating {@code ApplicationDispatcher}
+ * (Tomcat / SAP Commerce), which traverses a filter and valve chain adding significant call
+ * depth.
+ */
+ static class RealisticDispatcher implements RequestDispatcher {
+ @Override
+ public void forward(ServletRequest req, ServletResponse resp)
+ throws ServletException, IOException {
+ simulateFilterChain(200);
+ }
+
+ @Override
+ public void include(ServletRequest req, ServletResponse resp)
+ throws ServletException, IOException {}
+
+ private static void simulateFilterChain(int depth) {
+ if (depth > 0) simulateFilterChain(depth - 1);
+ }
+ }
+
+ /** Creates a proxy stub for {@code iface} where all methods return null / 0 / false. */
+ @SuppressWarnings("unchecked")
+ static Without the fix, the recursive {@code setAttribute()} pattern in Hybris would fill the call
+ * stack, and the extra frames from {@code simulateFilterChain()} would tip it into a {@code
+ * StackOverflowError} from the method body — captured by {@code @Advice.Thrown} → {@code
+ * DECORATE.onError()} → {@code span.setTag("error.stack")}.
+ *
+ * With the {@code CallDepthThreadLocalMap} fix, re-entrant {@code forward()} calls return
+ * immediately from the enter advice (depth > 0), so the stack never saturates and no SOE is
+ * thrown. The test verifies exactly this: {@code capturedSoe} must be {@code null}.
+ */
+ @Test
+ void forward_noSoe_withRealisticDispatcherBodyAndSetAttributeRecursion() throws Exception {
+ AtomicReference