diff --git a/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-3.4/build.gradle b/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-3.4/build.gradle index 56311523214..2344e2a0a81 100644 --- a/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-3.4/build.gradle +++ b/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-3.4/build.gradle @@ -42,10 +42,10 @@ dependencies { testImplementation group: 'io.vertx', name: 'vertx-web', version: '3.4.0' testImplementation group: 'io.vertx', name: 'vertx-web-client', version: '3.4.0' - testImplementation group: 'org.mockito', name: 'mockito-inline', version: '4.11.0' - testImplementation project(':dd-java-agent:appsec:appsec-test-fixtures') + testImplementation libs.junit.jupiter + latestDepTestImplementation group: 'io.vertx', name: 'vertx-web', version: '3.+' latestDepTestImplementation group: 'io.vertx', name: 'vertx-web-client', version: '3.+' } diff --git a/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-3.4/src/main/java/datadog/trace/instrumentation/vertx_3_4/server/EndHandlerWrapper.java b/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-3.4/src/main/java/datadog/trace/instrumentation/vertx_3_4/server/EndHandlerWrapper.java index a8cb1ebb079..f4b30bc4d44 100644 --- a/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-3.4/src/main/java/datadog/trace/instrumentation/vertx_3_4/server/EndHandlerWrapper.java +++ b/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-3.4/src/main/java/datadog/trace/instrumentation/vertx_3_4/server/EndHandlerWrapper.java @@ -1,9 +1,5 @@ package datadog.trace.instrumentation.vertx_3_4.server; -import static datadog.trace.instrumentation.vertx_3_4.server.RouteHandlerWrapper.HANDLER_SPAN_CONTEXT_KEY; -import static datadog.trace.instrumentation.vertx_3_4.server.VertxDecorator.DECORATE; - -import datadog.trace.bootstrap.instrumentation.api.AgentSpan; import io.vertx.core.Handler; import io.vertx.ext.web.RoutingContext; @@ -18,16 +14,12 @@ public class EndHandlerWrapper implements Handler { @Override public void handle(final Void event) { - AgentSpan span = routingContext.get(HANDLER_SPAN_CONTEXT_KEY); try { if (actual != null) { actual.handle(event); } } finally { - if (span != null) { - DECORATE.onResponse(span, routingContext.response()); - span.finish(); - } + RouteHandlerWrapper.finishHandlerSpan(routingContext); } } } diff --git a/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-3.4/src/main/java/datadog/trace/instrumentation/vertx_3_4/server/RouteHandlerWrapper.java b/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-3.4/src/main/java/datadog/trace/instrumentation/vertx_3_4/server/RouteHandlerWrapper.java index 37120c2f0cd..b001826420e 100644 --- a/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-3.4/src/main/java/datadog/trace/instrumentation/vertx_3_4/server/RouteHandlerWrapper.java +++ b/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-3.4/src/main/java/datadog/trace/instrumentation/vertx_3_4/server/RouteHandlerWrapper.java @@ -47,7 +47,16 @@ public void handle(final RoutingContext routingContext) { span = startSpan("vertx", INSTRUMENTATION_NAME); routingContext.put(HANDLER_SPAN_CONTEXT_KEY, span); + // Register three hooks that fire on response outcome: + // finishHandlerSpan is idempotent; whichever hook fires first wins. + // + // Known remaining gap: sendFile() failures on file-not-found or + // IOException-during-open log via HttpServerResponseImpl and return + // without firing any of the three hooks above (when the caller did + // not pass a resultHandler), so the span still leaks on that path. routingContext.response().endHandler(new EndHandlerWrapper(routingContext)); + routingContext.addBodyEndHandler(v -> finishHandlerSpan(routingContext)); + routingContext.response().exceptionHandler(t -> finishHandlerSpan(routingContext)); DECORATE.afterStart(span); span.setResourceName(DECORATE.className(actual.getClass())); } @@ -63,6 +72,20 @@ public void handle(final RoutingContext routingContext) { } } + // Idempotently finish the route-handler span. Any of the three registered + // hooks (EndHandlerWrapper, the addBodyEndHandler fallback, or the + // response.exceptionHandler lambda) may call this; the first one to win + // clears HANDLER_SPAN_CONTEXT_KEY so the others are no-ops. + static void finishHandlerSpan(final RoutingContext routingContext) { + final AgentSpan span = routingContext.get(HANDLER_SPAN_CONTEXT_KEY); + if (span == null) { + return; + } + routingContext.put(HANDLER_SPAN_CONTEXT_KEY, null); + DECORATE.onResponse(span, routingContext.response()); + span.finish(); + } + private void setRoute(RoutingContext routingContext) { final AgentSpan parentSpan = routingContext.get(PARENT_SPAN_CONTEXT_KEY); if (parentSpan == null) { diff --git a/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-3.4/src/test/java/io/vertx/core/http/impl/ResponseExceptionFiringHelper.java b/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-3.4/src/test/java/io/vertx/core/http/impl/ResponseExceptionFiringHelper.java new file mode 100644 index 00000000000..5c581365de5 --- /dev/null +++ b/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-3.4/src/test/java/io/vertx/core/http/impl/ResponseExceptionFiringHelper.java @@ -0,0 +1,17 @@ +package io.vertx.core.http.impl; + +import io.vertx.core.http.HttpServerResponse; + +/** + * Test-side bridge that fires the package-private HttpServerResponseImpl.handleException on a + * Vert.x 3.x server response. Used by server.RouteHandlerExceptionHandlerTest to deterministically + * reproduce the non-CLOSED_EXCEPTION I/O-failure path that Vert.x exposes via + * response.exceptionHandler(...). + */ +public final class ResponseExceptionFiringHelper { + private ResponseExceptionFiringHelper() {} + + public static void fireException(HttpServerResponse response, Throwable cause) { + ((HttpServerResponseImpl) response).handleException(cause); + } +} diff --git a/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-3.4/src/test/java/server/RouteHandlerExceptionHandlerTest.java b/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-3.4/src/test/java/server/RouteHandlerExceptionHandlerTest.java new file mode 100644 index 00000000000..3892646adcf --- /dev/null +++ b/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-3.4/src/test/java/server/RouteHandlerExceptionHandlerTest.java @@ -0,0 +1,126 @@ +package server; + +import static datadog.trace.agent.test.assertions.SpanMatcher.span; +import static datadog.trace.agent.test.assertions.TraceMatcher.SORT_BY_START_TIME; +import static datadog.trace.agent.test.assertions.TraceMatcher.trace; + +import datadog.trace.agent.test.AbstractInstrumentationTest; +import datadog.trace.api.DDSpanTypes; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpServer; +import io.vertx.core.http.impl.ResponseExceptionFiringHelper; +import io.vertx.ext.web.Router; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.ServerSocket; +import java.net.URL; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Regression test for the vertx-web 3.x route-handler span lifecycle on the + * response.exceptionHandler path. + * + *

HttpServerResponseImpl.handleException is invoked by Vert.x on non-CLOSED_EXCEPTION I/O + * failures of the response. Neither endHandler nor bodyEndHandler fires on this path, so the + * route-handler span would leak without an exception handler registered. The route handler here + * fires handleException directly via ResponseExceptionFiringHelper (the package-private method + * Vert.x itself uses internally), then calls response.end() normally so the HTTP client gets a + * response. + */ +class RouteHandlerExceptionHandlerTest extends AbstractInstrumentationTest { + + private static Vertx vertx; + private static HttpServer server; + private static int port; + + @BeforeAll + static void startServer() throws Exception { + try (ServerSocket socket = new ServerSocket(0)) { + port = socket.getLocalPort(); + } + + vertx = Vertx.vertx(); + Router router = Router.router(vertx); + router + .route("/fail") + .handler( + ctx -> { + ResponseExceptionFiringHelper.fireException( + ctx.response(), new IOException("simulated response I/O failure")); + try { + ctx.response().setStatusCode(500).end("error"); + } catch (IllegalStateException ignore) { + // handleException may have left the response in a state where end() is rejected; + // the span is already finished by our registered exception handler. + } + }); + + CountDownLatch ready = new CountDownLatch(1); + server = + vertx + .createHttpServer() + .requestHandler(router::accept) + .listen( + port, + result -> { + if (result.failed()) { + throw new RuntimeException("Failed to start Vert.x server", result.cause()); + } + ready.countDown(); + }); + if (!ready.await(10, TimeUnit.SECONDS)) { + throw new IllegalStateException("Vert.x server did not start in time"); + } + } + + @AfterAll + static void stopServer() throws Exception { + if (server != null) { + CountDownLatch closed = new CountDownLatch(1); + server.close(ar -> closed.countDown()); + closed.await(10, TimeUnit.SECONDS); + } + if (vertx != null) { + CountDownLatch closed = new CountDownLatch(1); + vertx.close(ar -> closed.countDown()); + closed.await(10, TimeUnit.SECONDS); + } + } + + @Test + void exceptionHandlerFinishesRouteHandlerSpan() throws Exception { + HttpURLConnection conn = + (HttpURLConnection) new URL("http://localhost:" + port + "/fail").openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(5000); + conn.setReadTimeout(5000); + try { + // We don't care about the response code or body — only that the trace flushes. + conn.getResponseCode(); + } catch (IOException ignore) { + // If end() was rejected after handleException, the client may see a closed connection. + } finally { + conn.disconnect(); + } + + // The netty.request span is marked as errored because the route handler ends with + // HTTP 500; the route-handler span is finished by our exception handler before + // setStatusCode(500), so it sees status=200 (default) and is not errored. + assertTraces( + trace( + SORT_BY_START_TIME, + span() + .operationName(Pattern.compile(Pattern.quote("netty.request"))) + .type(DDSpanTypes.HTTP_SERVER) + .error(), + span() + .childOfPrevious() + .operationName(Pattern.compile(Pattern.quote("vertx.route-handler"))) + .type(DDSpanTypes.HTTP_SERVER))); + } +} diff --git a/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-3.4/src/test/java/server/RouteHandlerSendFileTest.java b/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-3.4/src/test/java/server/RouteHandlerSendFileTest.java new file mode 100644 index 00000000000..06102063a1b --- /dev/null +++ b/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-3.4/src/test/java/server/RouteHandlerSendFileTest.java @@ -0,0 +1,122 @@ +package server; + +import static datadog.trace.agent.test.assertions.SpanMatcher.span; +import static datadog.trace.agent.test.assertions.TraceMatcher.SORT_BY_START_TIME; +import static datadog.trace.agent.test.assertions.TraceMatcher.trace; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import datadog.trace.agent.test.AbstractInstrumentationTest; +import datadog.trace.api.DDSpanTypes; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpServer; +import io.vertx.ext.web.Router; +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.ServerSocket; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Regression test for the vertx-web 3.x route-handler span lifecycle on the response.sendFile(...) + * path. + * + *

HttpServerResponseImpl.doSendFile (vertx-core 3.x) only invokes bodyEndHandler after the file + * is written; it never invokes endHandler. With only the endHandler registration (pre-fix), the + * vertx.route-handler span never finishes on this path, the trace fails to flush, and assertTraces + * times out. With the fallback addBodyEndHandler registration, the span finishes on every + * response-end path. + */ +class RouteHandlerSendFileTest extends AbstractInstrumentationTest { + + private static Vertx vertx; + private static HttpServer server; + private static int port; + private static Path payload; + + @BeforeAll + static void startServer() throws Exception { + payload = Files.createTempFile("vertx-sendfile-", ".txt"); + Files.write(payload, "vertx sendFile payload\n".getBytes(StandardCharsets.UTF_8)); + payload.toFile().deleteOnExit(); + + try (ServerSocket socket = new ServerSocket(0)) { + port = socket.getLocalPort(); + } + + vertx = Vertx.vertx(); + Router router = Router.router(vertx); + router + .route("/sendfile") + .handler(ctx -> ctx.response().sendFile(payload.toAbsolutePath().toString())); + + CountDownLatch ready = new CountDownLatch(1); + server = + vertx + .createHttpServer() + .requestHandler(router::accept) + .listen( + port, + result -> { + if (result.failed()) { + throw new RuntimeException("Failed to start Vert.x server", result.cause()); + } + ready.countDown(); + }); + if (!ready.await(10, TimeUnit.SECONDS)) { + throw new IllegalStateException("Vert.x server did not start in time"); + } + } + + @AfterAll + static void stopServer() throws Exception { + if (server != null) { + CountDownLatch closed = new CountDownLatch(1); + server.close(ar -> closed.countDown()); + closed.await(10, TimeUnit.SECONDS); + } + if (vertx != null) { + CountDownLatch closed = new CountDownLatch(1); + vertx.close(ar -> closed.countDown()); + closed.await(10, TimeUnit.SECONDS); + } + if (payload != null) { + Files.deleteIfExists(payload); + } + } + + @Test + void sendFileFinishesRouteHandlerSpan() throws Exception { + HttpURLConnection conn = + (HttpURLConnection) new URL("http://localhost:" + port + "/sendfile").openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(5000); + conn.setReadTimeout(5000); + assertEquals(200, conn.getResponseCode()); + try (BufferedReader reader = + new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { + assertEquals("vertx sendFile payload", reader.readLine()); + } + + // Pre-fix: the route-handler span never finishes on the sendFile path, so the trace + // is never published and assertTraces times out waiting for the trace to flush. + assertTraces( + trace( + SORT_BY_START_TIME, + span() + .operationName(Pattern.compile(Pattern.quote("netty.request"))) + .type(DDSpanTypes.HTTP_SERVER), + span() + .childOfPrevious() + .operationName(Pattern.compile(Pattern.quote("vertx.route-handler"))) + .type(DDSpanTypes.HTTP_SERVER))); + } +} diff --git a/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-4.0/build.gradle b/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-4.0/build.gradle index 096110203b4..ee305e1e172 100644 --- a/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-4.0/build.gradle +++ b/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-4.0/build.gradle @@ -45,6 +45,8 @@ dependencies { testImplementation project(':dd-java-agent:appsec:appsec-test-fixtures') + testImplementation libs.junit.jupiter + testRuntimeOnly project(':dd-java-agent:instrumentation:jackson-core:jackson-core-common') testRuntimeOnly project(':dd-java-agent:instrumentation:netty:netty-buffer-4.0') diff --git a/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-4.0/src/main/java/datadog/trace/instrumentation/vertx_4_0/server/EndHandlerWrapper.java b/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-4.0/src/main/java/datadog/trace/instrumentation/vertx_4_0/server/EndHandlerWrapper.java index a71584b11a7..fd4a042edf8 100644 --- a/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-4.0/src/main/java/datadog/trace/instrumentation/vertx_4_0/server/EndHandlerWrapper.java +++ b/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-4.0/src/main/java/datadog/trace/instrumentation/vertx_4_0/server/EndHandlerWrapper.java @@ -1,9 +1,5 @@ package datadog.trace.instrumentation.vertx_4_0.server; -import static datadog.trace.instrumentation.vertx_4_0.server.RouteHandlerWrapper.HANDLER_SPAN_CONTEXT_KEY; -import static datadog.trace.instrumentation.vertx_4_0.server.VertxDecorator.DECORATE; - -import datadog.trace.bootstrap.instrumentation.api.AgentSpan; import io.vertx.core.Handler; import io.vertx.ext.web.RoutingContext; @@ -18,16 +14,12 @@ public class EndHandlerWrapper implements Handler { @Override public void handle(final Void event) { - AgentSpan span = routingContext.get(HANDLER_SPAN_CONTEXT_KEY); try { if (actual != null) { actual.handle(event); } } finally { - if (span != null) { - DECORATE.onResponse(span, routingContext.response()); - span.finish(); - } + RouteHandlerWrapper.finishHandlerSpan(routingContext); } } } diff --git a/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-4.0/src/main/java/datadog/trace/instrumentation/vertx_4_0/server/RouteHandlerWrapper.java b/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-4.0/src/main/java/datadog/trace/instrumentation/vertx_4_0/server/RouteHandlerWrapper.java index 8706f816e1c..19b344b75f6 100644 --- a/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-4.0/src/main/java/datadog/trace/instrumentation/vertx_4_0/server/RouteHandlerWrapper.java +++ b/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-4.0/src/main/java/datadog/trace/instrumentation/vertx_4_0/server/RouteHandlerWrapper.java @@ -44,6 +44,16 @@ public void handle(final RoutingContext routingContext) { routingContext.put(HANDLER_SPAN_CONTEXT_KEY, span); routingContext.response().endHandler(new EndHandlerWrapper(routingContext)); + // Fallback finish path: HttpServerResponse.endHandler is silently skipped + // by Vert.x's Http1xServerResponse.end() when the underlying connection + // has already closed (Http1xServerResponse#end gates `endHandler.handle()` + // behind `!closed`). This happens in synthetic transports such as + // quarkus-amazon-lambda-rest's virtual Netty channel, where writes and + // close are synchronous in-memory, leaving the route-handler span unfinished + // and orphaning all jakarta-rs.request / aws.http child spans in the trace. + // RoutingContext#addEndHandler fires on routing-context completion regardless + // of underlying connection state and on both success and failure. + routingContext.addEndHandler(ar -> finishHandlerSpan(routingContext)); DECORATE.afterStart(span); span.setResourceName(DECORATE.className(actual.getClass())); } @@ -60,6 +70,19 @@ public void handle(final RoutingContext routingContext) { } } + // Idempotently finish the route-handler span. Both EndHandlerWrapper (the + // response.endHandler path) and the routingContext.addEndHandler fallback may call + // this; the first one to win clears HANDLER_SPAN_CONTEXT_KEY so the second is a no-op. + static void finishHandlerSpan(final RoutingContext routingContext) { + final AgentSpan span = routingContext.get(HANDLER_SPAN_CONTEXT_KEY); + if (span == null) { + return; + } + routingContext.put(HANDLER_SPAN_CONTEXT_KEY, null); + DECORATE.onResponse(span, routingContext.response()); + span.finish(); + } + private void setRoute(RoutingContext routingContext) { final AgentSpan parentSpan = routingContext.get(PARENT_SPAN_CONTEXT_KEY); if (parentSpan == null) { diff --git a/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-4.0/src/test/java/io/vertx/core/http/impl/ResponseExceptionFiringHelper.java b/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-4.0/src/test/java/io/vertx/core/http/impl/ResponseExceptionFiringHelper.java new file mode 100644 index 00000000000..4754c38843e --- /dev/null +++ b/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-4.0/src/test/java/io/vertx/core/http/impl/ResponseExceptionFiringHelper.java @@ -0,0 +1,17 @@ +package io.vertx.core.http.impl; + +import io.vertx.core.http.HttpServerResponse; + +/** + * Test-side bridge that fires the package-private Http1xServerResponse.handleException on a Vert.x + * 4.x server response. Used by server.RouteHandlerExceptionHandlerTest to deterministically + * reproduce the non-CLOSED_EXCEPTION I/O-failure path that Vert.x exposes via + * response.exceptionHandler(...). + */ +public final class ResponseExceptionFiringHelper { + private ResponseExceptionFiringHelper() {} + + public static void fireException(HttpServerResponse response, Throwable cause) { + ((Http1xServerResponse) response).handleException(cause); + } +} diff --git a/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-4.0/src/test/java/server/RouteHandlerExceptionHandlerTest.java b/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-4.0/src/test/java/server/RouteHandlerExceptionHandlerTest.java new file mode 100644 index 00000000000..fb91f77a4b7 --- /dev/null +++ b/dd-java-agent/instrumentation/vertx/vertx-web/vertx-web-4.0/src/test/java/server/RouteHandlerExceptionHandlerTest.java @@ -0,0 +1,130 @@ +package server; + +import static datadog.trace.agent.test.assertions.SpanMatcher.span; +import static datadog.trace.agent.test.assertions.TraceMatcher.SORT_BY_START_TIME; +import static datadog.trace.agent.test.assertions.TraceMatcher.trace; + +import datadog.trace.agent.test.AbstractInstrumentationTest; +import datadog.trace.api.DDSpanTypes; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpServer; +import io.vertx.core.http.impl.ResponseExceptionFiringHelper; +import io.vertx.ext.web.Router; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.ServerSocket; +import java.net.URL; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Regression test for the vertx-web 4.x route-handler span lifecycle on the + * response.exceptionHandler path. + * + *

Http1xServerResponse.handleException is invoked by Vert.x on non-CLOSED_EXCEPTION I/O failures + * of the response. Without RoutingContext.addEndHandler(...) registered, only the wrapped + * response.endHandler could finish the route-handler span — and that hook does not fire on the + * exception path. With the addEndHandler fallback in RouteHandlerWrapper, the routing context's + * internal exception handler fires our completion callback regardless of which response hook + * surfaces the error. The route handler here fires handleException directly via + * ResponseExceptionFiringHelper, then calls response.end() normally so the HTTP client gets a + * response. + */ +class RouteHandlerExceptionHandlerTest extends AbstractInstrumentationTest { + + private static Vertx vertx; + private static HttpServer server; + private static int port; + + @BeforeAll + static void startServer() throws Exception { + try (ServerSocket socket = new ServerSocket(0)) { + port = socket.getLocalPort(); + } + + vertx = Vertx.vertx(); + Router router = Router.router(vertx); + router + .route("/fail") + .handler( + ctx -> { + ResponseExceptionFiringHelper.fireException( + ctx.response(), new IOException("simulated response I/O failure")); + try { + ctx.response().setStatusCode(500).end("error"); + } catch (IllegalStateException ignore) { + // handleException may have left the response in a state where end() is rejected; + // the span is already finished by our addEndHandler callback. + } + }); + + CountDownLatch ready = new CountDownLatch(1); + server = + vertx + .createHttpServer() + .requestHandler(router) + .listen( + port, + result -> { + if (result.failed()) { + throw new RuntimeException("Failed to start Vert.x server", result.cause()); + } + ready.countDown(); + }); + if (!ready.await(10, TimeUnit.SECONDS)) { + throw new IllegalStateException("Vert.x server did not start in time"); + } + } + + @AfterAll + static void stopServer() throws Exception { + if (server != null) { + CountDownLatch closed = new CountDownLatch(1); + server.close(ar -> closed.countDown()); + closed.await(10, TimeUnit.SECONDS); + } + if (vertx != null) { + CountDownLatch closed = new CountDownLatch(1); + vertx.close(ar -> closed.countDown()); + closed.await(10, TimeUnit.SECONDS); + } + } + + @Test + void exceptionHandlerFinishesRouteHandlerSpan() throws Exception { + HttpURLConnection conn = + (HttpURLConnection) new URL("http://localhost:" + port + "/fail").openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(5000); + conn.setReadTimeout(5000); + try { + // We don't care about the response code or body — only that the trace flushes. + conn.getResponseCode(); + } catch (IOException ignore) { + // If end() was rejected after handleException, the client may see a closed connection. + } finally { + conn.disconnect(); + } + + // If addEndHandler did not finish the route-handler span, assertTraces would time out + // waiting for the trace to flush. + // The netty.request span is marked as errored because the route handler ends with + // HTTP 500; the route-handler span is finished by our addEndHandler callback before + // setStatusCode(500), so it sees status=200 (default) and is not errored. + assertTraces( + trace( + SORT_BY_START_TIME, + span() + .operationName(Pattern.compile(Pattern.quote("netty.request"))) + .type(DDSpanTypes.HTTP_SERVER) + .error(), + span() + .childOfPrevious() + .operationName(Pattern.compile(Pattern.quote("vertx.route-handler"))) + .type(DDSpanTypes.HTTP_SERVER))); + } +}