opentelemetry-java-instrume.../instrumentation/spring/spring-webflux-5.0/javaagent/src/test/groovy/SpringWebfluxTest.groovy

544 lines
21 KiB
Groovy

/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/
import static io.opentelemetry.api.trace.SpanKind.INTERNAL
import static io.opentelemetry.api.trace.SpanKind.SERVER
import static io.opentelemetry.api.trace.StatusCode.ERROR
import io.opentelemetry.instrumentation.test.AgentInstrumentationSpecification
import io.opentelemetry.semconv.trace.attributes.SemanticAttributes
import io.opentelemetry.testing.internal.armeria.client.ClientRequestContext
import io.opentelemetry.testing.internal.armeria.client.DecoratingHttpClientFunction
import io.opentelemetry.testing.internal.armeria.client.HttpClient
import io.opentelemetry.testing.internal.armeria.client.WebClient
import io.opentelemetry.testing.internal.armeria.common.HttpHeaderNames
import io.opentelemetry.testing.internal.armeria.common.HttpRequest
import io.opentelemetry.testing.internal.armeria.common.HttpResponse
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.boot.web.embedded.netty.NettyReactiveWebServerFactory
import org.springframework.boot.web.server.LocalServerPort
import org.springframework.context.annotation.Bean
import org.springframework.web.server.ResponseStatusException
import server.EchoHandlerFunction
import server.FooModel
import server.SpringWebFluxTestApplication
import server.TestController
import spock.lang.Unroll
import util.SpringWebfluxTestUtil
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = [SpringWebFluxTestApplication, ForceNettyAutoConfiguration])
class SpringWebfluxTest extends AgentInstrumentationSpecification {
@TestConfiguration
static class ForceNettyAutoConfiguration {
@Bean
NettyReactiveWebServerFactory nettyFactory() {
return new NettyReactiveWebServerFactory()
}
}
static final String INNER_HANDLER_FUNCTION_CLASS_TAG_PREFIX = SpringWebFluxTestApplication.getName() + "\$"
static final String SPRING_APP_CLASS_ANON_NESTED_CLASS_PREFIX = SpringWebFluxTestApplication.getSimpleName() + "\$"
@LocalServerPort
int port
WebClient client
def setup() {
client = WebClient.builder("h1c://localhost:$port")
.decorator(new DecoratingHttpClientFunction() {
// https://github.com/line/armeria/issues/2489
@Override
HttpResponse execute(HttpClient delegate, ClientRequestContext ctx, HttpRequest req) throws Exception {
return HttpResponse.from(delegate.execute(ctx, req).aggregate().thenApply {resp ->
if (resp.status().isRedirection()) {
return delegate.execute(ctx, HttpRequest.of(req.method(), resp.headers().get(HttpHeaderNames.LOCATION)))
}
return resp.toHttpResponse()
})
}
})
.build()
}
def cleanup() {
SpringWebfluxTestUtil.waitForRequestsToComplete()
}
@Unroll
def "Basic GET test #testName"() {
when:
def response = client.get(urlPath).aggregate().join()
then:
response.status().code() == 200
response.contentUtf8() == expectedResponseBody
assertTraces(1) {
trace(0, 2) {
span(0) {
name urlPathWithVariables
kind SERVER
hasNoParent()
attributes {
"${SemanticAttributes.NET_PEER_IP.key}" "127.0.0.1"
"${SemanticAttributes.NET_PEER_PORT.key}" Long
"${SemanticAttributes.HTTP_URL.key}" "http://localhost:$port$urlPath"
"${SemanticAttributes.HTTP_METHOD.key}" "GET"
"${SemanticAttributes.HTTP_STATUS_CODE.key}" 200
"${SemanticAttributes.HTTP_FLAVOR.key}" "1.1"
"${SemanticAttributes.HTTP_USER_AGENT.key}" String
}
}
span(1) {
if (annotatedMethod == null) {
// Functional API
nameContains(SPRING_APP_CLASS_ANON_NESTED_CLASS_PREFIX, ".handle")
} else {
// Annotation API
name TestController.getSimpleName() + "." + annotatedMethod
}
kind INTERNAL
childOf span(0)
attributes {
if (annotatedMethod == null) {
// Functional API
"spring-webflux.handler.type" { String tagVal ->
return tagVal.contains(INNER_HANDLER_FUNCTION_CLASS_TAG_PREFIX)
}
} else {
// Annotation API
"spring-webflux.handler.type" TestController.getName()
}
}
}
}
}
where:
testName | urlPath | urlPathWithVariables | annotatedMethod | expectedResponseBody
"functional API without parameters" | "/greet" | "/greet" | null | SpringWebFluxTestApplication.GreetingHandler.DEFAULT_RESPONSE
"functional API with one parameter" | "/greet/WORLD" | "/greet/{name}" | null | SpringWebFluxTestApplication.GreetingHandler.DEFAULT_RESPONSE + " WORLD"
"functional API with two parameters" | "/greet/World/Test1" | "/greet/{name}/{word}" | null | SpringWebFluxTestApplication.GreetingHandler.DEFAULT_RESPONSE + " World Test1"
"functional API delayed response" | "/greet-delayed" | "/greet-delayed" | null | SpringWebFluxTestApplication.GreetingHandler.DEFAULT_RESPONSE
"annotation API without parameters" | "/foo" | "/foo" | "getFooModel" | new FooModel(0L, "DEFAULT").toString()
"annotation API with one parameter" | "/foo/1" | "/foo/{id}" | "getFooModel" | new FooModel(1L, "pass").toString()
"annotation API with two parameters" | "/foo/2/world" | "/foo/{id}/{name}" | "getFooModel" | new FooModel(2L, "world").toString()
"annotation API delayed response" | "/foo-delayed" | "/foo-delayed" | "getFooDelayed" | new FooModel(3L, "delayed").toString()
}
@Unroll
def "GET test with async response #testName"() {
when:
def response = client.get(urlPath).aggregate().join()
then:
response.status().code() == 200
response.contentUtf8() == expectedResponseBody
assertTraces(1) {
trace(0, 3) {
span(0) {
name urlPathWithVariables
kind SERVER
hasNoParent()
attributes {
"${SemanticAttributes.NET_PEER_IP.key}" "127.0.0.1"
"${SemanticAttributes.NET_PEER_PORT.key}" Long
"${SemanticAttributes.HTTP_URL.key}" "http://localhost:$port$urlPath"
"${SemanticAttributes.HTTP_METHOD.key}" "GET"
"${SemanticAttributes.HTTP_STATUS_CODE.key}" 200
"${SemanticAttributes.HTTP_FLAVOR.key}" "1.1"
"${SemanticAttributes.HTTP_USER_AGENT.key}" String
}
}
span(1) {
if (annotatedMethod == null) {
// Functional API
nameContains(SPRING_APP_CLASS_ANON_NESTED_CLASS_PREFIX, ".handle")
} else {
// Annotation API
name TestController.getSimpleName() + "." + annotatedMethod
}
kind INTERNAL
childOf span(0)
attributes {
if (annotatedMethod == null) {
// Functional API
"spring-webflux.handler.type" { String tagVal ->
return tagVal.contains(INNER_HANDLER_FUNCTION_CLASS_TAG_PREFIX)
}
} else {
// Annotation API
"spring-webflux.handler.type" TestController.getName()
}
}
}
span(2) {
name "tracedMethod"
childOf span(0)
attributes {
}
}
}
}
where:
testName | urlPath | urlPathWithVariables | annotatedMethod | expectedResponseBody
"functional API traced method from mono" | "/greet-mono-from-callable/4" | "/greet-mono-from-callable/{id}" | null | SpringWebFluxTestApplication.GreetingHandler.DEFAULT_RESPONSE + " 4"
"functional API traced method with delay" | "/greet-delayed-mono/6" | "/greet-delayed-mono/{id}" | null | SpringWebFluxTestApplication.GreetingHandler.DEFAULT_RESPONSE + " 6"
"annotation API traced method from mono" | "/foo-mono-from-callable/7" | "/foo-mono-from-callable/{id}" | "getMonoFromCallable" | new FooModel(7L, "tracedMethod").toString()
"annotation API traced method with delay" | "/foo-delayed-mono/9" | "/foo-delayed-mono/{id}" | "getFooDelayedMono" | new FooModel(9L, "tracedMethod").toString()
}
/*
This test differs from the previous in one important aspect.
The test above calls endpoints which does not create any spans during their invocation.
They merely assemble reactive pipeline where some steps create spans.
Thus all those spans are created when WebFlux span created by DispatcherHandlerInstrumentation
has already finished. Therefore, they have `SERVER` span as their parent.
This test below calls endpoints which do create spans right inside endpoint handler.
Therefore, in theory, those spans should have INTERNAL span created by DispatcherHandlerInstrumentation
as their parent. But there is a difference how Spring WebFlux handles functional endpoints
(created in server.SpringWebFluxTestApplication.greetRouterFunction) and annotated endpoints
(created in server.TestController).
In the former case org.springframework.web.reactive.function.server.support.HandlerFunctionAdapter.handle
calls handler function directly. Thus "tracedMethod" span below has INTERNAL handler span as its parent.
In the latter case org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerAdapter.handle
merely wraps handler call into Mono and thus actual invocation of handler function happens later,
when INTERNAL handler span has already finished. Thus, "tracedMethod" has SERVER Netty span as its parent.
*/
def "Create span during handler function"() {
when:
def response = client.get(urlPath).aggregate().join()
then:
response.status().code() == 200
response.contentUtf8() == expectedResponseBody
assertTraces(1) {
trace(0, 3) {
span(0) {
name urlPathWithVariables
kind SERVER
hasNoParent()
attributes {
"${SemanticAttributes.NET_PEER_IP.key}" "127.0.0.1"
"${SemanticAttributes.NET_PEER_PORT.key}" Long
"${SemanticAttributes.HTTP_URL.key}" "http://localhost:$port$urlPath"
"${SemanticAttributes.HTTP_METHOD.key}" "GET"
"${SemanticAttributes.HTTP_STATUS_CODE.key}" 200
"${SemanticAttributes.HTTP_FLAVOR.key}" "1.1"
"${SemanticAttributes.HTTP_USER_AGENT.key}" String
}
}
span(1) {
if (annotatedMethod == null) {
// Functional API
nameContains(SPRING_APP_CLASS_ANON_NESTED_CLASS_PREFIX, ".handle")
} else {
// Annotation API
name TestController.getSimpleName() + "." + annotatedMethod
}
kind INTERNAL
childOf span(0)
attributes {
if (annotatedMethod == null) {
// Functional API
"spring-webflux.handler.type" { String tagVal ->
return tagVal.contains(INNER_HANDLER_FUNCTION_CLASS_TAG_PREFIX)
}
} else {
// Annotation API
"spring-webflux.handler.type" TestController.getName()
}
}
}
span(2) {
name "tracedMethod"
childOf span(annotatedMethod ? 0 : 1)
attributes {
}
}
}
}
where:
testName | urlPath | urlPathWithVariables | annotatedMethod | expectedResponseBody
"functional API traced method" | "/greet-traced-method/5" | "/greet-traced-method/{id}" | null | SpringWebFluxTestApplication.GreetingHandler.DEFAULT_RESPONSE + " 5"
"annotation API traced method" | "/foo-traced-method/8" | "/foo-traced-method/{id}" | "getTracedMethod" | new FooModel(8L, "tracedMethod").toString()
}
def "404 GET test"() {
when:
def response = client.get("/notfoundgreet").aggregate().join()
then:
response.status().code() == 404
assertTraces(1) {
trace(0, 2) {
span(0) {
name "/**"
kind SERVER
hasNoParent()
status ERROR
attributes {
"${SemanticAttributes.NET_PEER_IP.key}" "127.0.0.1"
"${SemanticAttributes.NET_PEER_PORT.key}" Long
"${SemanticAttributes.HTTP_URL.key}" "http://localhost:$port/notfoundgreet"
"${SemanticAttributes.HTTP_METHOD.key}" "GET"
"${SemanticAttributes.HTTP_STATUS_CODE.key}" 404
"${SemanticAttributes.HTTP_FLAVOR.key}" "1.1"
"${SemanticAttributes.HTTP_USER_AGENT.key}" String
}
}
span(1) {
name "ResourceWebHandler.handle"
kind INTERNAL
childOf span(0)
status ERROR
errorEvent(ResponseStatusException, String)
attributes {
"spring-webflux.handler.type" "org.springframework.web.reactive.resource.ResourceWebHandler"
}
}
}
}
}
def "Basic POST test"() {
setup:
String echoString = "TEST"
when:
def response = client.post("/echo", echoString).aggregate().join()
then:
response.status().code() == 202
response.contentUtf8() == echoString
assertTraces(1) {
trace(0, 3) {
span(0) {
name "/echo"
kind SERVER
hasNoParent()
attributes {
"${SemanticAttributes.NET_PEER_IP.key}" "127.0.0.1"
"${SemanticAttributes.NET_PEER_PORT.key}" Long
"${SemanticAttributes.HTTP_URL.key}" "http://localhost:$port/echo"
"${SemanticAttributes.HTTP_METHOD.key}" "POST"
"${SemanticAttributes.HTTP_STATUS_CODE.key}" 202
"${SemanticAttributes.HTTP_FLAVOR.key}" "1.1"
"${SemanticAttributes.HTTP_USER_AGENT.key}" String
}
}
span(1) {
name EchoHandlerFunction.getSimpleName() + ".handle"
kind INTERNAL
childOf span(0)
attributes {
"spring-webflux.handler.type" { String tagVal ->
return tagVal.contains(EchoHandlerFunction.getName())
}
}
}
span(2) {
name "echo"
childOf span(1)
attributes {
}
}
}
}
}
@Unroll
def "GET to bad endpoint #testName"() {
when:
def response = client.get(urlPath).aggregate().join()
then:
response.status().code() == 500
assertTraces(1) {
trace(0, 2) {
span(0) {
name urlPathWithVariables
kind SERVER
status ERROR
hasNoParent()
attributes {
"${SemanticAttributes.NET_PEER_IP.key}" "127.0.0.1"
"${SemanticAttributes.NET_PEER_PORT.key}" Long
"${SemanticAttributes.HTTP_URL.key}" "http://localhost:$port$urlPath"
"${SemanticAttributes.HTTP_METHOD.key}" "GET"
"${SemanticAttributes.HTTP_STATUS_CODE.key}" 500
"${SemanticAttributes.HTTP_FLAVOR.key}" "1.1"
"${SemanticAttributes.HTTP_USER_AGENT.key}" String
}
}
span(1) {
if (annotatedMethod == null) {
// Functional API
nameContains(SPRING_APP_CLASS_ANON_NESTED_CLASS_PREFIX, ".handle")
} else {
// Annotation API
name TestController.getSimpleName() + "." + annotatedMethod
}
kind INTERNAL
childOf span(0)
status ERROR
errorEvent(IllegalStateException, "bad things happen")
attributes {
if (annotatedMethod == null) {
// Functional API
"spring-webflux.handler.type" { String tagVal ->
return tagVal.contains(INNER_HANDLER_FUNCTION_CLASS_TAG_PREFIX)
}
} else {
// Annotation API
"spring-webflux.handler.type" TestController.getName()
}
}
}
}
}
where:
testName | urlPath | urlPathWithVariables | annotatedMethod
"functional API fail fast" | "/greet-failfast/1" | "/greet-failfast/{id}" | null
"functional API fail Mono" | "/greet-failmono/1" | "/greet-failmono/{id}" | null
"annotation API fail fast" | "/foo-failfast/1" | "/foo-failfast/{id}" | "getFooFailFast"
"annotation API fail Mono" | "/foo-failmono/1" | "/foo-failmono/{id}" | "getFooFailMono"
}
def "Redirect test"() {
setup:
String finalUrl = "http://localhost:$port/double-greet"
when:
def response = client.get("/double-greet-redirect").aggregate().join()
then:
response.status().code() == 200
assertTraces(2) {
// TODO: why order of spans is different in these traces?
trace(0, 2) {
span(0) {
name "/double-greet-redirect"
kind SERVER
hasNoParent()
attributes {
"${SemanticAttributes.NET_PEER_IP.key}" "127.0.0.1"
"${SemanticAttributes.NET_PEER_PORT.key}" Long
"${SemanticAttributes.HTTP_URL.key}" "http://localhost:$port/double-greet-redirect"
"${SemanticAttributes.HTTP_METHOD.key}" "GET"
"${SemanticAttributes.HTTP_STATUS_CODE.key}" 307
"${SemanticAttributes.HTTP_FLAVOR.key}" "1.1"
"${SemanticAttributes.HTTP_USER_AGENT.key}" String
}
}
span(1) {
name "RedirectComponent.lambda"
kind INTERNAL
childOf span(0)
attributes {
"spring-webflux.handler.type" { String tagVal ->
return (tagVal.contains(INNER_HANDLER_FUNCTION_CLASS_TAG_PREFIX)
|| tagVal.contains("Lambda"))
}
}
}
}
trace(1, 2) {
span(0) {
name "/double-greet"
kind SERVER
hasNoParent()
attributes {
"${SemanticAttributes.NET_PEER_IP.key}" "127.0.0.1"
"${SemanticAttributes.NET_PEER_PORT.key}" Long
"${SemanticAttributes.HTTP_URL.key}" finalUrl
"${SemanticAttributes.HTTP_METHOD.key}" "GET"
"${SemanticAttributes.HTTP_STATUS_CODE.key}" 200
"${SemanticAttributes.HTTP_FLAVOR.key}" "1.1"
"${SemanticAttributes.HTTP_USER_AGENT.key}" String
}
}
span(1) {
nameContains(SpringWebFluxTestApplication.getSimpleName() + "\$", ".handle")
kind INTERNAL
childOf span(0)
attributes {
"spring-webflux.handler.type" { String tagVal ->
return tagVal.contains(INNER_HANDLER_FUNCTION_CLASS_TAG_PREFIX)
}
}
}
}
}
}
@Unroll
def "Multiple GETs to delaying route #testName"() {
setup:
def requestsCount = 50 // Should be more than 2x CPUs to fish out some bugs
def url = "http://localhost:$port$urlPath"
when:
def responses = (0..requestsCount - 1).collect { client.get(urlPath).aggregate().join() }
then:
responses.every { it.status().code() == 200 }
responses.every { it.contentUtf8() == expectedResponseBody }
assertTraces(responses.size()) {
responses.eachWithIndex { def response, int i ->
trace(i, 2) {
span(0) {
name urlPathWithVariables
kind SERVER
hasNoParent()
attributes {
"${SemanticAttributes.NET_PEER_IP.key}" "127.0.0.1"
"${SemanticAttributes.NET_PEER_PORT.key}" Long
"${SemanticAttributes.HTTP_URL.key}" url
"${SemanticAttributes.HTTP_METHOD.key}" "GET"
"${SemanticAttributes.HTTP_STATUS_CODE.key}" 200
"${SemanticAttributes.HTTP_FLAVOR.key}" "1.1"
"${SemanticAttributes.HTTP_USER_AGENT.key}" String
}
}
span(1) {
if (annotatedMethod == null) {
// Functional API
nameContains(SPRING_APP_CLASS_ANON_NESTED_CLASS_PREFIX, ".handle")
} else {
// Annotation API
name TestController.getSimpleName() + "." + annotatedMethod
}
kind INTERNAL
childOf span(0)
attributes {
if (annotatedMethod == null) {
// Functional API
"spring-webflux.handler.type" { String tagVal ->
return tagVal.contains(INNER_HANDLER_FUNCTION_CLASS_TAG_PREFIX)
}
} else {
// Annotation API
"spring-webflux.handler.type" TestController.getName()
}
}
}
}
}
}
where:
testName | urlPath | urlPathWithVariables | annotatedMethod | expectedResponseBody
"functional API delayed response" | "/greet-delayed" | "/greet-delayed" | null | SpringWebFluxTestApplication.GreetingHandler.DEFAULT_RESPONSE
"annotation API delayed response" | "/foo-delayed" | "/foo-delayed" | "getFooDelayed" | new FooModel(3L, "delayed").toString()
}
}