Merge pull request #990 from DataDog/tyler/spring-web

Migrate spring-web to base httpserver test
This commit is contained in:
Tyler Benson 2019-09-17 08:24:37 -07:00 committed by GitHub
commit f14bfe7803
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 236 additions and 479 deletions

View File

@ -43,7 +43,7 @@ public class TestServlets {
}
}
@WebServlet("/error")
@WebServlet("/error-status")
public static class Error extends HttpServlet {
@Override
protected void service(final HttpServletRequest req, final HttpServletResponse resp) {

View File

@ -55,7 +55,7 @@ class GrizzlyAsyncTest extends GrizzlyTest {
}
@GET
@Path("error")
@Path("error-status")
void error(@Suspended AsyncResponse ar) {
executor.execute {
controller(ERROR) {

View File

@ -74,7 +74,7 @@ class GrizzlyTest extends HttpServerTest<HttpServer, GrizzlyDecorator> {
}
@GET
@Path("error")
@Path("error-status")
Response error() {
controller(ERROR) {
Response.status(ERROR.status).entity(ERROR.body).build()

View File

@ -29,5 +29,4 @@ dependencies {
testCompile group: 'org.springframework.boot', name: 'spring-boot-starter-test', version: '1.5.17.RELEASE'
testCompile group: 'org.springframework.boot', name: 'spring-boot-starter-web', version: '1.5.17.RELEASE'
testCompile group: 'org.springframework.boot', name: 'spring-boot-starter-security', version: '1.5.17.RELEASE'
}

View File

@ -0,0 +1,47 @@
package test
import org.apache.catalina.connector.Connector
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.embedded.EmbeddedServletContainerFactory
import org.springframework.boot.context.embedded.tomcat.TomcatConnectorCustomizer
import org.springframework.boot.context.embedded.tomcat.TomcatEmbeddedServletContainerFactory
import org.springframework.context.annotation.Bean
import org.springframework.http.MediaType
import org.springframework.web.HttpMediaTypeNotAcceptableException
import org.springframework.web.accept.ContentNegotiationStrategy
import org.springframework.web.context.request.NativeWebRequest
import org.springframework.web.servlet.config.annotation.ContentNegotiationConfigurer
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter
@SpringBootApplication
class AppConfig extends WebMvcConfigurerAdapter {
@Override
void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
configurer.favorPathExtension(false)
.favorParameter(true)
.ignoreAcceptHeader(true)
.useJaf(false)
.defaultContentTypeStrategy(new ContentNegotiationStrategy() {
@Override
List<MediaType> resolveMediaTypes(NativeWebRequest webRequest) throws HttpMediaTypeNotAcceptableException {
return [MediaType.TEXT_PLAIN, MediaType.APPLICATION_JSON]
}
})
}
@Bean
EmbeddedServletContainerFactory servletContainerFactory() {
def factory = new TomcatEmbeddedServletContainerFactory()
factory.addConnectorCustomizers(
new TomcatConnectorCustomizer() {
@Override
void customize(final Connector connector) {
connector.setEnableLookups(true)
}
})
return factory
}
}

View File

@ -0,0 +1,22 @@
package test;
import static net.bytebuddy.matcher.ElementMatchers.named;
import com.google.auto.service.AutoService;
import datadog.trace.agent.test.base.HttpServerTestAdvice;
import datadog.trace.agent.tooling.Instrumenter;
import net.bytebuddy.agent.builder.AgentBuilder;
@AutoService(Instrumenter.class)
public class ServletTestInstrumentation implements Instrumenter {
@Override
public AgentBuilder instrument(final AgentBuilder agentBuilder) {
return agentBuilder
// Tomcat
.type(named("org.apache.catalina.connector.CoyoteAdapter"))
.transform(
new AgentBuilder.Transformer.ForAdvice()
.advice(named("service"), HttpServerTestAdvice.ServerEntryAdvice.class.getName()));
}
}

View File

@ -1,394 +1,146 @@
package test
import datadog.trace.agent.test.AgentTestRunner
import datadog.opentracing.DDSpan
import datadog.trace.agent.test.asserts.ListWriterAssert
import datadog.trace.agent.test.asserts.SpanAssert
import datadog.trace.agent.test.asserts.TraceAssert
import datadog.trace.agent.test.base.HttpServerTest
import datadog.trace.api.DDSpanTypes
import datadog.trace.api.DDTags
import datadog.trace.instrumentation.servlet3.Servlet3Decorator
import datadog.trace.instrumentation.springweb.SpringWebHttpServerDecorator
import groovy.transform.stc.ClosureParams
import groovy.transform.stc.SimpleType
import io.opentracing.tag.Tags
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.context.embedded.LocalServerPort
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.http.HttpStatus
import org.springframework.web.bind.MethodArgumentNotValidException
import org.apache.catalina.core.ApplicationFilterChain
import org.springframework.boot.SpringApplication
import org.springframework.context.ConfigurableApplicationContext
import org.springframework.web.servlet.view.RedirectView
import static test.Application.PASS
import static test.Application.USER
import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.EXCEPTION
import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.SUCCESS
import static java.util.Collections.singletonMap
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class SpringBootBasedTest extends AgentTestRunner {
class SpringBootBasedTest extends HttpServerTest<ConfigurableApplicationContext, Servlet3Decorator> {
@LocalServerPort
private int port
@Override
ConfigurableApplicationContext startServer(int port) {
def app = new SpringApplication(AppConfig)
app.setDefaultProperties(singletonMap("server.port", port))
def context = app.run()
return context
}
@Autowired
private TestRestTemplate restTemplate
@Override
void stopServer(ConfigurableApplicationContext ctx) {
ctx.close()
}
def "valid response"() {
expect:
port != 0
restTemplate.withBasicAuth(USER, PASS)
.getForObject("http://localhost:$port/", String) == "Hello World"
@Override
Servlet3Decorator decorator() {
return Servlet3Decorator.DECORATE
}
and:
assertTraces(1) {
trace(0, 2) {
span(0) {
operationName "servlet.request"
resourceName "GET /"
spanType DDSpanTypes.HTTP_SERVER
parent()
@Override
String expectedOperationName() {
return "servlet.request"
}
@Override
boolean hasHandlerSpan() {
true
}
@Override
boolean testNotFound() {
// FIXME: the instrumentation adds an extra controller span which is not consistent.
// Fix tests or remove extra span.
false
}
void cleanAndAssertTraces(
final int size,
@ClosureParams(value = SimpleType, options = "datadog.trace.agent.test.asserts.ListWriterAssert")
@DelegatesTo(value = ListWriterAssert, strategy = Closure.DELEGATE_FIRST)
final Closure spec) {
// If this is failing, make sure HttpServerTestAdvice is applied correctly.
TEST_WRITER.waitForTraces(size * 2)
TEST_WRITER.each {
def renderSpan = it.find {
it.operationName == "response.render"
}
if (renderSpan) {
SpanAssert.assertSpan(renderSpan) {
operationName "response.render"
resourceName "response.render"
spanType "web"
errored false
tags {
"http.url" "http://localhost:$port/"
"http.method" "GET"
"peer.hostname" "127.0.0.1"
"peer.ipv4" "127.0.0.1"
"peer.port" Integer
"component" "spring-webmvc"
"view.type" RedirectView.name
"span.kind" "server"
"span.origin.type" "org.apache.catalina.core.ApplicationFilterChain"
"component" "java-web-servlet"
"http.status_code" 200
"$DDTags.USER_NAME" USER
defaultTags()
}
}
controllerSpan(it, 1, "TestController.greeting")
}
}
}
def "generates spans"() {
setup:
def entity = restTemplate.withBasicAuth(USER, PASS)
.getForEntity("http://localhost:$port/param/$param/", String)
expect:
entity.statusCode == status
if (entity.hasBody()) {
entity.body == "Hello asdf1234"
}
assertTraces(1) {
trace(0, 2) {
span(0) {
operationName "servlet.request"
resourceName(status.value == 404 ? "404" : "GET /param/{parameter}/")
spanType DDSpanTypes.HTTP_SERVER
parent()
errored false
tags {
"http.url" "http://localhost:$port/param/$param/"
"http.method" "GET"
"peer.hostname" "127.0.0.1"
"peer.ipv4" "127.0.0.1"
"peer.port" Integer
"span.kind" "server"
"span.origin.type" "org.apache.catalina.core.ApplicationFilterChain"
"component" "java-web-servlet"
"http.status_code" status.value
"$DDTags.USER_NAME" USER
defaultTags()
}
}
controllerSpan(it, 1, "TestController.withParam")
it.remove(renderSpan)
}
}
where:
param | status
"asdf1234" | HttpStatus.OK
"missing" | HttpStatus.NOT_FOUND
super.cleanAndAssertTraces(size, spec)
}
def "missing auth"() {
setup:
def resp = restTemplate.getForObject("http://localhost:$port/param/asdf1234/", Map)
expect:
resp["status"] == 401
resp["error"] == "Unauthorized"
assertTraces(2) {
trace(0, 1) {
span(0) {
operationName "servlet.request"
resourceName "GET /param/?/"
spanType DDSpanTypes.HTTP_SERVER
parent()
errored false
tags {
"http.url" "http://localhost:$port/param/asdf1234/"
"http.method" "GET"
"peer.hostname" "127.0.0.1"
"peer.ipv4" "127.0.0.1"
"peer.port" Integer
"span.kind" "server"
"span.origin.type" "org.apache.catalina.core.ApplicationFilterChain"
"component" "java-web-servlet"
"http.status_code" 401
defaultTags()
}
}
}
trace(1, 2) {
span(0) {
operationName "servlet.request"
resourceName "GET /error"
spanType DDSpanTypes.HTTP_SERVER
parent()
errored false
tags {
"http.url" "http://localhost:$port/error"
"http.method" "GET"
"peer.hostname" "127.0.0.1"
"peer.ipv4" "127.0.0.1"
"peer.port" Integer
"span.kind" "server"
"span.origin.type" "org.apache.catalina.core.ApplicationFilterChain"
"component" "java-web-servlet"
"http.status_code" 401
defaultTags()
}
}
controllerSpan(it, 1, "BasicErrorController.error")
}
}
}
def "generates 404 spans"() {
setup:
def response = restTemplate.withBasicAuth(USER, PASS)
.getForObject("http://localhost:$port/invalid", Map)
expect:
response.get("status") == 404
response.get("error") == "Not Found"
assertTraces(2) {
trace(0, 2) {
span(0) {
operationName "servlet.request"
resourceName "404"
spanType DDSpanTypes.HTTP_SERVER
parent()
errored false
tags {
"http.url" "http://localhost:$port/invalid"
"http.method" "GET"
"peer.hostname" "127.0.0.1"
"peer.ipv4" "127.0.0.1"
"peer.port" Integer
"span.kind" "server"
"span.origin.type" "org.apache.catalina.core.ApplicationFilterChain"
"component" "java-web-servlet"
"http.status_code" 404
"$DDTags.USER_NAME" USER
defaultTags()
}
}
controllerSpan(it, 1, "ResourceHttpRequestHandler.handleRequest")
}
trace(1, 2) {
span(0) {
operationName "servlet.request"
resourceName "404"
spanType DDSpanTypes.HTTP_SERVER
parent()
errored false
tags {
"http.url" "http://localhost:$port/error"
"http.method" "GET"
"peer.hostname" "127.0.0.1"
"peer.ipv4" "127.0.0.1"
"peer.port" Integer
"span.kind" "server"
"span.origin.type" "org.apache.catalina.core.ApplicationFilterChain"
"component" "java-web-servlet"
"http.status_code" 404
defaultTags()
}
}
controllerSpan(it, 1, "BasicErrorController.error")
}
}
}
def "generates error spans"() {
setup:
def response = restTemplate.withBasicAuth(USER, PASS)
.getForObject("http://localhost:$port/error/qwerty/", Map)
expect:
response.get("status") == 500
response.get("error") == "Internal Server Error"
response.get("exception") == "java.lang.RuntimeException"
response.get("message") == "qwerty"
assertTraces(2) {
trace(0, 2) {
span(0) {
operationName "servlet.request"
resourceName "GET /error/{parameter}/"
spanType DDSpanTypes.HTTP_SERVER
parent()
errored true
tags {
"http.url" "http://localhost:$port/error/qwerty/"
"http.method" "GET"
"peer.hostname" "127.0.0.1"
"peer.ipv4" "127.0.0.1"
"peer.port" Integer
"span.kind" "server"
"span.origin.type" "org.apache.catalina.core.ApplicationFilterChain"
"component" "java-web-servlet"
"http.status_code" 500
"$DDTags.USER_NAME" USER
errorTags RuntimeException, "qwerty"
defaultTags()
}
}
controllerSpan(it, 1, "TestController.withError", RuntimeException)
}
trace(1, 2) {
span(0) {
operationName "servlet.request"
resourceName "GET /error"
spanType DDSpanTypes.HTTP_SERVER
parent()
errored true
tags {
"http.url" "http://localhost:$port/error"
"http.method" "GET"
"peer.hostname" "127.0.0.1"
"peer.ipv4" "127.0.0.1"
"peer.port" Integer
"span.kind" "server"
"span.origin.type" "org.apache.catalina.core.ApplicationFilterChain"
"component" "java-web-servlet"
"http.status_code" 500
"error" true
defaultTags()
}
}
controllerSpan(it, 1, "BasicErrorController.error")
}
}
}
def "validated form"() {
expect:
restTemplate.withBasicAuth(USER, PASS)
.postForObject("http://localhost:$port/validated", new TestForm("bob", 20), String) == "Hello bob Person(Name: bob, Age: 20)"
assertTraces(1) {
trace(0, 2) {
span(0) {
operationName "servlet.request"
resourceName "POST /validated"
spanType DDSpanTypes.HTTP_SERVER
parent()
errored false
tags {
"http.url" "http://localhost:$port/validated"
"http.method" "POST"
"peer.hostname" "127.0.0.1"
"peer.ipv4" "127.0.0.1"
"peer.port" Integer
"span.kind" "server"
"span.origin.type" "org.apache.catalina.core.ApplicationFilterChain"
"component" "java-web-servlet"
"http.status_code" 200
"$DDTags.USER_NAME" USER
defaultTags()
}
}
controllerSpan(it, 1, "TestController.withValidation")
}
}
}
def "invalid form"() {
setup:
def response = restTemplate.withBasicAuth(USER, PASS)
.postForObject("http://localhost:$port/validated", new TestForm("bill", 5), Map, Map)
expect:
response.get("status") == 400
response.get("error") == "Bad Request"
response.get("exception") == "org.springframework.web.bind.MethodArgumentNotValidException"
response.get("message") == "Validation failed for object='testForm'. Error count: 1"
assertTraces(2) {
trace(0, 2) {
span(0) {
operationName "servlet.request"
resourceName "POST /validated"
spanType DDSpanTypes.HTTP_SERVER
parent()
errored false
tags {
"http.url" "http://localhost:$port/validated"
"http.method" "POST"
"peer.hostname" "127.0.0.1"
"peer.ipv4" "127.0.0.1"
"peer.port" Integer
"span.kind" "server"
"span.origin.type" "org.apache.catalina.core.ApplicationFilterChain"
"component" "java-web-servlet"
"http.status_code" 400
"$DDTags.USER_NAME" USER
"error" false
"error.msg" String
"error.type" MethodArgumentNotValidException.name
"error.stack" String
defaultTags()
}
}
controllerSpan(it, 1, "TestController.withValidation", MethodArgumentNotValidException)
}
trace(1, 2) {
span(0) {
operationName "servlet.request"
resourceName "POST /error"
spanType DDSpanTypes.HTTP_SERVER
parent()
errored false
tags {
"http.url" "http://localhost:$port/error"
"http.method" "POST"
"peer.hostname" "127.0.0.1"
"peer.ipv4" "127.0.0.1"
"peer.port" Integer
"span.kind" "server"
"span.origin.type" "org.apache.catalina.core.ApplicationFilterChain"
"component" "java-web-servlet"
"http.status_code" 400
defaultTags()
}
}
controllerSpan(it, 1, "BasicErrorController.error")
}
}
}
def controllerSpan(TraceAssert trace, int index, String name, Class<Throwable> errorType = null) {
@Override
void handlerSpan(TraceAssert trace, int index, Object parent, ServerEndpoint endpoint = SUCCESS) {
trace.span(index) {
serviceName "unnamed-java-app"
operationName name
resourceName name
serviceName expectedServiceName()
operationName "TestController.${endpoint.name().toLowerCase()}"
spanType DDSpanTypes.HTTP_SERVER
childOf(trace.span(0))
errored errorType != null
errored endpoint == EXCEPTION
childOf(parent as DDSpan)
tags {
"$Tags.COMPONENT.key" "spring-web-controller"
"$Tags.COMPONENT.key" SpringWebHttpServerDecorator.DECORATE.component()
"$Tags.SPAN_KIND.key" Tags.SPAN_KIND_SERVER
if (errorType) {
"error.msg" String
errorTags(errorType)
}
defaultTags()
if (endpoint == EXCEPTION) {
errorTags(Exception, EXCEPTION.body)
}
}
}
}
@Override
void serverSpan(TraceAssert trace, int index, String traceID = null, String parentID = null, String method = "GET", ServerEndpoint endpoint = SUCCESS) {
trace.span(index) {
serviceName expectedServiceName()
operationName expectedOperationName()
resourceName endpoint.status == 404 ? "404" : "$method ${endpoint.resolve(address).path}"
spanType DDSpanTypes.HTTP_SERVER
errored endpoint.errored
if (parentID != null) {
traceId traceID
parentId parentID
} else {
parent()
}
tags {
"span.origin.type" ApplicationFilterChain.name
defaultTags(true)
"$Tags.COMPONENT.key" serverDecorator.component()
if (endpoint.errored) {
"$Tags.ERROR.key" endpoint.errored
"error.msg" { it == null || it == EXCEPTION.body }
"error.type" { it == null || it == Exception.name }
"error.stack" { it == null || it instanceof String }
}
"$Tags.HTTP_STATUS.key" endpoint.status
"$Tags.HTTP_URL.key" "${endpoint.resolve(address)}"
"$Tags.PEER_HOSTNAME.key" { it == "localhost" || it == "127.0.0.1" }
"$Tags.PEER_PORT.key" Integer
"$Tags.PEER_HOST_IPV4.key" { it == null || it == "127.0.0.1" } // Optional
"$Tags.HTTP_METHOD.key" method
"$Tags.SPAN_KIND.key" Tags.SPAN_KIND_SERVER
}
}
}

View File

@ -0,0 +1,54 @@
package test
import datadog.trace.agent.test.base.HttpServerTest
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.ResponseBody
import org.springframework.web.servlet.view.RedirectView
import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.ERROR
import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.EXCEPTION
import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.REDIRECT
import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.SUCCESS
@Controller
class TestController {
@RequestMapping("/success")
@ResponseBody
String success() {
HttpServerTest.controller(SUCCESS) {
SUCCESS.body
}
}
@RequestMapping("/redirect")
@ResponseBody
RedirectView redirect() {
HttpServerTest.controller(REDIRECT) {
new RedirectView(REDIRECT.body)
}
}
@RequestMapping("/error-status")
ResponseEntity error() {
HttpServerTest.controller(ERROR) {
new ResponseEntity(ERROR.body, HttpStatus.valueOf(ERROR.status))
}
}
@RequestMapping("/exception")
ResponseEntity exception() {
HttpServerTest.controller(EXCEPTION) {
throw new Exception(EXCEPTION.body)
}
}
@ExceptionHandler
ResponseEntity handleException(Throwable throwable) {
new ResponseEntity(throwable.message, HttpStatus.INTERNAL_SERVER_ERROR)
}
}

View File

@ -1,39 +0,0 @@
package test;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
@SpringBootApplication
public class Application {
public static final String USER = "username";
public static final String PASS = "password";
private static final String ROLE = "USER";
public static void main(final String[] args) {
SpringApplication.run(Application.class, args);
}
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(final HttpSecurity http) throws Exception {
http.csrf().disable().authorizeRequests().anyRequest().authenticated().and().httpBasic();
}
@Bean
@Override
public UserDetailsService userDetailsService() {
return new InMemoryUserDetailsManager(
User.withUsername(USER).password(PASS).roles(ROLE).build());
}
}
}

View File

@ -1,37 +0,0 @@
package test;
import javax.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseBody;
@Controller
public class TestController {
@GetMapping("/")
public @ResponseBody String greeting() {
return "Hello World";
}
@GetMapping("/param/{parameter}/")
public ResponseEntity<String> withParam(@PathVariable("parameter") final String param) {
if (param.equals("missing")) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok("Hello " + param);
}
@PostMapping("/validated")
public @ResponseBody String withValidation(@Valid @RequestBody final TestForm form) {
return "Hello " + form.getName() + " " + form;
}
@GetMapping("/error/{parameter}/")
public @ResponseBody String withError(@PathVariable("parameter") final String param) {
throw new RuntimeException(param);
}
}

View File

@ -1,44 +0,0 @@
package test;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
public class TestForm {
@NotNull
@Size(min = 2, max = 30)
private String name;
@NotNull
@Min(18)
private Integer age;
public TestForm() {}
public TestForm(final String name, final Integer age) {
this.name = name;
this.age = age;
}
public String getName() {
return this.name;
}
public void setName(final String name) {
this.name = name;
}
public Integer getAge() {
return age;
}
public void setAge(final Integer age) {
this.age = age;
}
@Override
public String toString() {
return "Person(Name: " + this.name + ", Age: " + this.age + ")";
}
}

View File

@ -106,9 +106,12 @@ abstract class HttpServerTest<SERVER, DECORATOR extends HttpServerDecorator> ext
enum ServerEndpoint {
SUCCESS("success", 200, "success"),
REDIRECT("redirect", 302, "/redirected"),
ERROR("error", 500, "controller error"),
ERROR("error-status", 500, "controller error"), // "error" is a special path for some frameworks
EXCEPTION("exception", 500, "controller exception"),
NOT_FOUND("notFound", 404, "not found"),
// TODO: add tests for the following cases:
PATH_PARAM("path/123/param", 200, "123"),
AUTH_REQUIRED("authRequired", 200, null),
private final String path
@ -244,7 +247,7 @@ abstract class HttpServerTest<SERVER, DECORATOR extends HttpServerDecorator> ext
if (hasHandlerSpan()) {
trace(0, 3) {
serverSpan(it, 0, null, null, method, REDIRECT)
handlerSpan(it, 1, span(0))
handlerSpan(it, 1, span(0), REDIRECT)
controllerSpan(it, 2, span(1))
}
} else {