diff --git a/instrumentation/spring/spring-webflux-5.0/javaagent/src/test/groovy/client/SpringWebFluxSingleConnection.groovy b/instrumentation/spring/spring-webflux-5.0/javaagent/src/test/groovy/client/SpringWebFluxSingleConnection.groovy new file mode 100644 index 000000000000..09babd6355dc --- /dev/null +++ b/instrumentation/spring/spring-webflux-5.0/javaagent/src/test/groovy/client/SpringWebFluxSingleConnection.groovy @@ -0,0 +1,67 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package client + +import io.netty.channel.ChannelOption +import io.opentelemetry.instrumentation.test.base.HttpClientTest +import io.opentelemetry.instrumentation.test.base.SingleConnection +import org.springframework.http.HttpMethod +import org.springframework.http.client.reactive.ReactorClientHttpConnector +import org.springframework.web.reactive.function.client.WebClient +import reactor.ipc.netty.http.client.HttpClientOptions +import reactor.ipc.netty.resources.PoolResources + +import java.util.concurrent.ExecutionException +import java.util.concurrent.TimeoutException + +class SpringWebFluxSingleConnection implements SingleConnection { + private final ReactorClientHttpConnector connector + private final String host + private final int port + + SpringWebFluxSingleConnection(boolean isOldVersion, String host, int port) { + if (isOldVersion) { + connector = new ReactorClientHttpConnector({ HttpClientOptions.Builder clientOptions -> + clientOptions.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, HttpClientTest.CONNECT_TIMEOUT_MS) + clientOptions.poolResources(PoolResources.fixed("pool", 1, HttpClientTest.CONNECT_TIMEOUT_MS)) + }) + } else { + def httpClient = reactor.netty.http.client.HttpClient.create(reactor.netty.resources.create("pool", 1)).tcpConfiguration({ tcpClient -> + tcpClient.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, HttpClientTest.CONNECT_TIMEOUT_MS) + }) + connector = new ReactorClientHttpConnector(httpClient) + } + + this.host = host + this.port = port + } + + @Override + int doRequest(String path, Map headers) throws ExecutionException, InterruptedException, TimeoutException { + String requestId = Objects.requireNonNull(headers.get(REQUEST_ID_HEADER)) + + URI uri + try { + uri = new URL("http", host, port, path).toURI() + } catch (MalformedURLException e) { + throw new ExecutionException(e) + } + + def request = WebClient.builder().clientConnector(connector).build().method(HttpMethod.GET) + .uri(uri) + .headers { h -> headers.forEach({ key, value -> h.add(key, value) }) } + + def response = request.exchange().block() + + String responseId = response.headers().asHttpHeaders().getFirst(REQUEST_ID_HEADER) + if (requestId != responseId) { + throw new IllegalStateException( + String.format("Received response with id %s, expected %s", responseId, requestId)) + } + + return response.statusCode().value() + } +} diff --git a/instrumentation/spring/spring-webflux-5.0/javaagent/src/test/groovy/client/SpringWebfluxHttpClientTest.groovy b/instrumentation/spring/spring-webflux-5.0/javaagent/src/test/groovy/client/SpringWebfluxHttpClientTest.groovy index 444d0bb8affc..3399398e2be4 100644 --- a/instrumentation/spring/spring-webflux-5.0/javaagent/src/test/groovy/client/SpringWebfluxHttpClientTest.groovy +++ b/instrumentation/spring/spring-webflux-5.0/javaagent/src/test/groovy/client/SpringWebfluxHttpClientTest.groovy @@ -9,6 +9,7 @@ import io.netty.channel.ChannelOption import io.opentelemetry.instrumentation.test.AgentTestTrait import io.opentelemetry.instrumentation.test.asserts.SpanAssert import io.opentelemetry.instrumentation.test.base.HttpClientTest +import io.opentelemetry.instrumentation.test.base.SingleConnection import org.springframework.http.HttpMethod import org.springframework.http.client.reactive.ReactorClientHttpConnector import org.springframework.web.reactive.function.client.WebClient @@ -77,4 +78,9 @@ class SpringWebfluxHttpClientTest extends HttpClientTest getApplicationClass() { + return Application + } + + @Configuration + @EnableAutoConfiguration + static class Application { + @Bean + Controller controller() { + return new Controller() + } + + @Bean + NettyReactiveWebServerFactory nettyFactory() { + return new NettyReactiveWebServerFactory() + } + } + + @RestController + static class Controller extends ServerTestController { + @Override + protected Mono wrapControllerMethod( + HttpServerTest.ServerEndpoint endpoint, Callable handler) { + + return Mono.just("") + .delayElement(Duration.ofMillis(10)) + .map({ controller(endpoint, handler) }) + } + } +} diff --git a/instrumentation/spring/spring-webflux-5.0/javaagent/src/test/groovy/server/base/DelayedHandlerSpringWebFluxServerTest.groovy b/instrumentation/spring/spring-webflux-5.0/javaagent/src/test/groovy/server/base/DelayedHandlerSpringWebFluxServerTest.groovy new file mode 100644 index 000000000000..af63835644c4 --- /dev/null +++ b/instrumentation/spring/spring-webflux-5.0/javaagent/src/test/groovy/server/base/DelayedHandlerSpringWebFluxServerTest.groovy @@ -0,0 +1,60 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package server.base + +import io.opentelemetry.instrumentation.test.base.HttpServerTest +import java.time.Duration +import org.springframework.boot.autoconfigure.EnableAutoConfiguration +import org.springframework.boot.web.embedded.netty.NettyReactiveWebServerFactory +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.reactive.function.server.RouterFunction +import org.springframework.web.reactive.function.server.ServerResponse +import reactor.core.publisher.Mono + +/** + * Tests the case which uses route handlers, and where "controller" span is created within a Mono + * map step, which follows a delay step. For exception endpoint, the exception is thrown within the + * last map step. + */ +class DelayedHandlerSpringWebFluxServerTest extends HandlerSpringWebFluxServerTest { + @Override + protected Class getApplicationClass() { + return Application + } + + @Configuration + @EnableAutoConfiguration + static class Application { + @Bean + RouterFunction router() { + return new RouteFactory().createRoutes() + } + + @Bean + NettyReactiveWebServerFactory nettyFactory() { + return new NettyReactiveWebServerFactory() + } + } + + static class RouteFactory extends ServerTestRouteFactory { + + @Override + protected Mono wrapResponse(HttpServerTest.ServerEndpoint endpoint, Mono response, Runnable spanAction) { + return response.delayElement(Duration.ofMillis(10)).map({ original -> + return controller(endpoint, { + spanAction.run() + return original + }) + }) + } + } + + @Override + boolean hasHandlerAsControllerParentSpan(HttpServerTest.ServerEndpoint endpoint) { + return false + } +} diff --git a/instrumentation/spring/spring-webflux-5.0/javaagent/src/test/groovy/server/base/HandlerSpringWebFluxServerTest.groovy b/instrumentation/spring/spring-webflux-5.0/javaagent/src/test/groovy/server/base/HandlerSpringWebFluxServerTest.groovy new file mode 100644 index 000000000000..dbdc8abae2e4 --- /dev/null +++ b/instrumentation/spring/spring-webflux-5.0/javaagent/src/test/groovy/server/base/HandlerSpringWebFluxServerTest.groovy @@ -0,0 +1,38 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package server.base + +import static io.opentelemetry.api.trace.SpanKind.INTERNAL +import static io.opentelemetry.instrumentation.test.base.HttpServerTest.ServerEndpoint.EXCEPTION +import static io.opentelemetry.instrumentation.test.base.HttpServerTest.ServerEndpoint.NOT_FOUND + +import io.opentelemetry.api.trace.StatusCode +import io.opentelemetry.instrumentation.test.asserts.TraceAssert +import io.opentelemetry.instrumentation.test.base.HttpServerTest +import io.opentelemetry.sdk.trace.data.SpanData +import org.springframework.web.server.ResponseStatusException + +abstract class HandlerSpringWebFluxServerTest extends SpringWebFluxServerTest { + @Override + void handlerSpan(TraceAssert trace, int index, Object parent, String method, HttpServerTest.ServerEndpoint endpoint) { + def handlerSpanName = "${ServerTestRouteFactory.simpleName}.lambda" + if (endpoint == NOT_FOUND) { + handlerSpanName = "ResourceWebHandler.handle" + } + trace.span(index) { + name handlerSpanName + kind INTERNAL + if (endpoint == EXCEPTION) { + status StatusCode.ERROR + errorEvent(RuntimeException, EXCEPTION.body) + } else if (endpoint == NOT_FOUND) { + status StatusCode.ERROR + errorEvent(ResponseStatusException, "Response status 404") + } + childOf((SpanData) parent) + } + } +} diff --git a/instrumentation/spring/spring-webflux-5.0/javaagent/src/test/groovy/server/base/ImmediateControllerSpringWebFluxServerTest.groovy b/instrumentation/spring/spring-webflux-5.0/javaagent/src/test/groovy/server/base/ImmediateControllerSpringWebFluxServerTest.groovy new file mode 100644 index 000000000000..7ea4599b3832 --- /dev/null +++ b/instrumentation/spring/spring-webflux-5.0/javaagent/src/test/groovy/server/base/ImmediateControllerSpringWebFluxServerTest.groovy @@ -0,0 +1,49 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package server.base + +import io.opentelemetry.instrumentation.test.base.HttpServerTest +import java.util.concurrent.Callable +import org.springframework.boot.autoconfigure.EnableAutoConfiguration +import org.springframework.boot.web.embedded.netty.NettyReactiveWebServerFactory +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.bind.annotation.RestController +import reactor.core.publisher.Mono + +/** + * Tests the case where "controller" span is created within the controller method scope, and the + * Mono from a handler is already a fully constructed response with no deferred actions. + * For exception endpoint, the exception is thrown within controller method scope. + */ +class ImmediateControllerSpringWebFluxServerTest extends ControllerSpringWebFluxServerTest { + @Override + protected Class getApplicationClass() { + return Application + } + + @Configuration + @EnableAutoConfiguration + static class Application { + @Bean + Controller controller() { + return new Controller() + } + + @Bean + NettyReactiveWebServerFactory nettyFactory() { + return new NettyReactiveWebServerFactory() + } + } + + @RestController + static class Controller extends ServerTestController { + @Override + protected Mono wrapControllerMethod(HttpServerTest.ServerEndpoint endpoint, Callable controllerMethod) { + return Mono.just(controller(endpoint, controllerMethod)) + } + } +} diff --git a/instrumentation/spring/spring-webflux-5.0/javaagent/src/test/groovy/server/base/ImmediateHandlerSpringWebFluxServerTest.groovy b/instrumentation/spring/spring-webflux-5.0/javaagent/src/test/groovy/server/base/ImmediateHandlerSpringWebFluxServerTest.groovy new file mode 100644 index 000000000000..a1433ea24b4e --- /dev/null +++ b/instrumentation/spring/spring-webflux-5.0/javaagent/src/test/groovy/server/base/ImmediateHandlerSpringWebFluxServerTest.groovy @@ -0,0 +1,52 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package server.base + +import io.opentelemetry.instrumentation.test.base.HttpServerTest +import org.springframework.boot.autoconfigure.EnableAutoConfiguration +import org.springframework.boot.web.embedded.netty.NettyReactiveWebServerFactory +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.reactive.function.server.RouterFunction +import org.springframework.web.reactive.function.server.ServerResponse +import reactor.core.publisher.Mono + +/** + * Tests the case where "controller" span is created within the route handler method scope, and + * the Mono from a handler is already a fully constructed response with no deferred + * actions. For exception endpoint, the exception is thrown within route handler method scope. + */ +class ImmediateHandlerSpringWebFluxServerTest extends HandlerSpringWebFluxServerTest { + @Override + protected Class getApplicationClass() { + return Application + } + + @Configuration + @EnableAutoConfiguration + static class Application { + @Bean + RouterFunction router() { + return new RouteFactory().createRoutes() + } + + @Bean + NettyReactiveWebServerFactory nettyFactory() { + return new NettyReactiveWebServerFactory() + } + } + + static class RouteFactory extends ServerTestRouteFactory { + + @Override + protected Mono wrapResponse(HttpServerTest.ServerEndpoint endpoint, Mono response, Runnable spanAction) { + return controller(endpoint, { + spanAction.run() + return response + }) + } + } +} diff --git a/instrumentation/spring/spring-webflux-5.0/javaagent/src/test/groovy/server/base/SpringWebFluxServerTest.groovy b/instrumentation/spring/spring-webflux-5.0/javaagent/src/test/groovy/server/base/SpringWebFluxServerTest.groovy new file mode 100644 index 000000000000..8215418e256a --- /dev/null +++ b/instrumentation/spring/spring-webflux-5.0/javaagent/src/test/groovy/server/base/SpringWebFluxServerTest.groovy @@ -0,0 +1,67 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package server.base + +import static io.opentelemetry.instrumentation.test.base.HttpServerTest.ServerEndpoint.NOT_FOUND +import static io.opentelemetry.instrumentation.test.base.HttpServerTest.ServerEndpoint.PATH_PARAM + +import io.opentelemetry.instrumentation.test.AgentTestTrait +import io.opentelemetry.instrumentation.test.base.HttpServerTest +import org.springframework.boot.SpringApplication +import org.springframework.context.ConfigurableApplicationContext + +abstract class SpringWebFluxServerTest extends HttpServerTest implements AgentTestTrait { + protected abstract Class getApplicationClass(); + + @Override + ConfigurableApplicationContext startServer(int port) { + def app = new SpringApplication(getApplicationClass()) + app.setDefaultProperties([ + "server.port" : port, + "server.context-path" : getContextPath(), + "server.servlet.contextPath" : getContextPath(), + "server.error.include-message": "always"]) + def context = app.run() + return context + } + + @Override + void stopServer(ConfigurableApplicationContext ctx) { + ctx.close() + } + + @Override + String expectedServerSpanName(ServerEndpoint endpoint) { + switch (endpoint) { + case PATH_PARAM: + return getContextPath() + "/path/{id}/param" + case NOT_FOUND: + return "/**" + default: + return super.expectedServerSpanName(endpoint) + } + } + + @Override + boolean hasHandlerSpan(ServerEndpoint endpoint) { + return true + } + + @Override + boolean testPathParam() { + return true + } + + @Override + boolean testConcurrency() { + return true + } + + @Override + Class expectedExceptionClass() { + return RuntimeException + } +} diff --git a/instrumentation/spring/spring-webflux-5.0/javaagent/src/test/groovy/server/base/package-info.groovy b/instrumentation/spring/spring-webflux-5.0/javaagent/src/test/groovy/server/base/package-info.groovy new file mode 100644 index 000000000000..38a003ec08fb --- /dev/null +++ b/instrumentation/spring/spring-webflux-5.0/javaagent/src/test/groovy/server/base/package-info.groovy @@ -0,0 +1,5 @@ +/** + * The classes in this package are specific to tests that extend + * {@link io.opentelemetry.instrumentation.test.base.HttpServerTest}. + */ +package server.base diff --git a/instrumentation/spring/spring-webflux-5.0/javaagent/src/test/java/server/base/ServerTestController.java b/instrumentation/spring/spring-webflux-5.0/javaagent/src/test/java/server/base/ServerTestController.java new file mode 100644 index 000000000000..54ac8f144e9b --- /dev/null +++ b/instrumentation/spring/spring-webflux-5.0/javaagent/src/test/java/server/base/ServerTestController.java @@ -0,0 +1,112 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package server.base; + +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.instrumentation.test.base.HttpServerTest.ServerEndpoint; +import java.net.URI; +import java.util.concurrent.Callable; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import reactor.core.publisher.Mono; + +public abstract class ServerTestController { + @GetMapping("/success") + public Mono success(ServerHttpResponse response) { + ServerEndpoint endpoint = ServerEndpoint.SUCCESS; + + return wrapControllerMethod( + endpoint, + () -> { + setStatus(response, endpoint); + return endpoint.getBody(); + }); + } + + @GetMapping("/query") + public Mono query_param(ServerHttpRequest request, ServerHttpResponse response) { + ServerEndpoint endpoint = ServerEndpoint.QUERY_PARAM; + + return wrapControllerMethod( + endpoint, + () -> { + setStatus(response, endpoint); + return request.getURI().getRawQuery(); + }); + } + + @GetMapping("/redirect") + public Mono redirect(ServerHttpResponse response) { + ServerEndpoint endpoint = ServerEndpoint.REDIRECT; + + return wrapControllerMethod( + endpoint, + () -> { + setStatus(response, endpoint); + response.getHeaders().setLocation(URI.create(endpoint.getBody())); + return ""; + }); + } + + @GetMapping("/error-status") + Mono error(ServerHttpResponse response) { + ServerEndpoint endpoint = ServerEndpoint.ERROR; + + return wrapControllerMethod( + endpoint, + () -> { + setStatus(response, endpoint); + return endpoint.getBody(); + }); + } + + @GetMapping("/exception") + Mono exception() throws Exception { + ServerEndpoint endpoint = ServerEndpoint.EXCEPTION; + + return wrapControllerMethod( + endpoint, + () -> { + throw new RuntimeException(endpoint.getBody()); + }); + } + + @GetMapping("/path/{id}/param") + Mono path_param(ServerHttpResponse response, @PathVariable("id") String id) { + ServerEndpoint endpoint = ServerEndpoint.PATH_PARAM; + + return wrapControllerMethod( + endpoint, + () -> { + setStatus(response, endpoint); + return id; + }); + } + + @GetMapping("/child") + Mono indexed_child(ServerHttpRequest request, ServerHttpResponse response) { + ServerEndpoint endpoint = ServerEndpoint.INDEXED_CHILD; + + return wrapControllerMethod( + endpoint, + () -> { + Span.current() + .setAttribute( + "test.request.id", Long.parseLong(request.getQueryParams().getFirst("id"))); + setStatus(response, endpoint); + return ""; + }); + } + + protected abstract Mono wrapControllerMethod(ServerEndpoint endpoint, Callable handler); + + private static void setStatus(ServerHttpResponse response, ServerEndpoint endpoint) { + response.setStatusCode(HttpStatus.resolve(endpoint.getStatus())); + } +} diff --git a/instrumentation/spring/spring-webflux-5.0/javaagent/src/test/java/server/base/ServerTestRouteFactory.java b/instrumentation/spring/spring-webflux-5.0/javaagent/src/test/java/server/base/ServerTestRouteFactory.java new file mode 100644 index 000000000000..7bf88d522c6e --- /dev/null +++ b/instrumentation/spring/spring-webflux-5.0/javaagent/src/test/java/server/base/ServerTestRouteFactory.java @@ -0,0 +1,108 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package server.base; + +import static org.springframework.web.reactive.function.server.RequestPredicates.GET; +import static org.springframework.web.reactive.function.server.RouterFunctions.route; + +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.instrumentation.test.base.HttpServerTest.ServerEndpoint; +import org.springframework.http.HttpHeaders; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.ServerResponse; +import org.springframework.web.reactive.function.server.ServerResponse.BodyBuilder; +import reactor.core.publisher.Mono; + +public abstract class ServerTestRouteFactory { + public RouterFunction createRoutes() { + return route( + GET("/success"), + request -> { + ServerEndpoint endpoint = ServerEndpoint.SUCCESS; + + return respond(endpoint, null, null, null); + }) + .andRoute( + GET("/query"), + request -> { + ServerEndpoint endpoint = ServerEndpoint.QUERY_PARAM; + + return respond(endpoint, null, request.uri().getRawQuery(), null); + }) + .andRoute( + GET("/redirect"), + request -> { + ServerEndpoint endpoint = ServerEndpoint.REDIRECT; + + return respond( + endpoint, + ServerResponse.status(endpoint.getStatus()) + .header(HttpHeaders.LOCATION, endpoint.getBody()), + "", + null); + }) + .andRoute( + GET("/error-status"), + redirect -> { + ServerEndpoint endpoint = ServerEndpoint.ERROR; + + return respond(endpoint, null, null, null); + }) + .andRoute( + GET("/exception"), + request -> { + ServerEndpoint endpoint = ServerEndpoint.EXCEPTION; + + return respond( + endpoint, + ServerResponse.ok(), + "", + () -> { + throw new RuntimeException(endpoint.getBody()); + }); + }) + .andRoute( + GET("/path/{id}/param"), + request -> { + ServerEndpoint endpoint = ServerEndpoint.PATH_PARAM; + + return respond(endpoint, null, request.pathVariable("id"), null); + }) + .andRoute( + GET("/child"), + request -> { + ServerEndpoint endpoint = ServerEndpoint.INDEXED_CHILD; + + return respond( + endpoint, + null, + null, + () -> { + Span.current() + .setAttribute( + "test.request.id", Long.parseLong(request.queryParam("id").get())); + }); + }); + } + + protected Mono respond( + ServerEndpoint endpoint, BodyBuilder bodyBuilder, String body, Runnable spanAction) { + if (bodyBuilder == null) { + bodyBuilder = ServerResponse.status(endpoint.getStatus()); + } + if (body == null) { + body = endpoint.getBody() != null ? endpoint.getBody() : ""; + } + if (spanAction == null) { + spanAction = () -> {}; + } + + return wrapResponse(endpoint, bodyBuilder.syncBody(body), spanAction); + } + + protected abstract Mono wrapResponse( + ServerEndpoint endpoint, Mono response, Runnable spanAction); +} diff --git a/testing-common/src/main/groovy/io/opentelemetry/instrumentation/test/base/HttpServerTest.groovy b/testing-common/src/main/groovy/io/opentelemetry/instrumentation/test/base/HttpServerTest.groovy index be46fbdcf139..04e5bee2b627 100644 --- a/testing-common/src/main/groovy/io/opentelemetry/instrumentation/test/base/HttpServerTest.groovy +++ b/testing-common/src/main/groovy/io/opentelemetry/instrumentation/test/base/HttpServerTest.groovy @@ -60,6 +60,10 @@ abstract class HttpServerTest extends InstrumentationSpecification imple false } + boolean hasHandlerAsControllerParentSpan(ServerEndpoint endpoint) { + true + } + boolean hasExceptionOnServerSpan(ServerEndpoint endpoint) { !hasHandlerSpan(endpoint) } @@ -446,7 +450,8 @@ abstract class HttpServerTest extends InstrumentationSpecification imple controllerSpanIndex++ } - indexedControllerSpan(it, controllerSpanIndex, span(controllerSpanIndex - 1), requestId) + def controllerParentSpanIndex = controllerSpanIndex - (hasHandlerAsControllerParentSpan(endpoint) ? 1 : 2) + indexedControllerSpan(it, controllerSpanIndex, span(controllerParentSpanIndex), requestId) } } } @@ -484,7 +489,7 @@ abstract class HttpServerTest extends InstrumentationSpecification imple } if (endpoint != NOT_FOUND) { def controllerSpanIndex = 0 - if (hasHandlerSpan(endpoint)) { + if (hasHandlerSpan(endpoint) && hasHandlerAsControllerParentSpan(endpoint)) { controllerSpanIndex++ } controllerSpan(it, spanIndex++, span(controllerSpanIndex), errorMessage, expectedExceptionClass())