diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/HttpServerDecorator.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/HttpServerDecorator.java index 60ebcf8a1f9..9cc7132402b 100644 --- a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/HttpServerDecorator.java +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/HttpServerDecorator.java @@ -230,6 +230,12 @@ private void registerServiceEntrySpanInInferredProxy( } } + /** Returns the application HTTP server span, beneath any inferred proxy span. */ + protected static AgentSpan getHttpServerSpan(AgentSpan span) { + InferredProxySpan inferredProxy = InferredProxySpan.fromContext(Context.current()); + return inferredProxy != null ? inferredProxy.getServiceEntrySpan() : span.getLocalRootSpan(); + } + private void resetServiceNameIfUnderInferredProxy(Context parentContext, AgentSpan span) { if (InferredProxySpan.fromContext(parentContext) != null) { span.setServiceName(Config.get().getServiceName()); diff --git a/dd-java-agent/agent-tooling/src/main/resources/datadog/trace/agent/tooling/bytebuddy/matcher/ignored_class_name.trie b/dd-java-agent/agent-tooling/src/main/resources/datadog/trace/agent/tooling/bytebuddy/matcher/ignored_class_name.trie index 7a57990c9f5..9ebe433dcce 100644 --- a/dd-java-agent/agent-tooling/src/main/resources/datadog/trace/agent/tooling/bytebuddy/matcher/ignored_class_name.trie +++ b/dd-java-agent/agent-tooling/src/main/resources/datadog/trace/agent/tooling/bytebuddy/matcher/ignored_class_name.trie @@ -162,7 +162,10 @@ 0 akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport 0 akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport$class 0 akka.http.scaladsl.unmarshalling.* -0 akka.http.scaladsl.server.PathMatcher$Matched +# HTTP routing supplies route templates when OTel HTTP semantics are enabled +0 akka.http.scaladsl.server.Directive* +0 akka.http.scaladsl.server.PathMatcher* +0 akka.http.scaladsl.server.ImplicitPathMatcherConstruction* 0 akka.http.scaladsl.server.directives.ParameterDirectives$class 0 akka.http.scaladsl.server.directives.ParameterDirectives 0 akka.http.scaladsl.server.directives.BasicDirectives$class diff --git a/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/build.gradle b/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/build.gradle index 57739ecbbd5..dc342eeeb1a 100644 --- a/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/build.gradle +++ b/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/build.gradle @@ -12,7 +12,9 @@ addForkedTestTask('baseTest') addTestSuiteForDir('version101Test', 'baseTest') addTestSuiteExtendingForDir('version101ForkedTest', 'version101Test', 'baseTest') addTestSuiteForDir('version102Scala213Test', 'latestDepTest') +addForkedTestTask('version102Scala213Test') addTestSuite('latestDepTest') +addForkedTestTask('latestDepTest') addTestSuite('lagomTest') addTestSuite('iastTest') addTestSuiteForDir 'version101IastTest', 'iastTest' diff --git a/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/src/baseTest/groovy/AkkaHttpServerInstrumentationTest.groovy b/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/src/baseTest/groovy/AkkaHttpServerInstrumentationTest.groovy index c4619379863..c652aa8551f 100644 --- a/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/src/baseTest/groovy/AkkaHttpServerInstrumentationTest.groovy +++ b/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/src/baseTest/groovy/AkkaHttpServerInstrumentationTest.groovy @@ -1,5 +1,6 @@ import datadog.trace.agent.test.base.HttpServer import datadog.trace.agent.test.base.HttpServerTest +import datadog.trace.agent.test.base.WithHttpServer import datadog.trace.agent.test.naming.TestingGenericHttpNamingConventions import datadog.trace.agent.test.naming.TestingOtelHttpSemantics import datadog.trace.api.config.TraceInstrumentationConfig @@ -279,6 +280,167 @@ class AkkaHttpServerInstrumentationSyncV1ForkedTest extends AkkaHttpServerInstru class AkkaHttpServerInstrumentationSyncOtelSemanticsForkedTest extends AkkaHttpServerInstrumentationSyncV0ForkedTest implements TestingOtelHttpSemantics { } +abstract class AkkaHttpRouteOtelSemanticsTest extends WithHttpServer implements TestingGenericHttpNamingConventions.ServerV0, TestingOtelHttpSemantics { + @Override + HttpServer server() { + new AkkaHttpTestWebServer(AkkaHttpTestWebServer.BindAndHandle()) + } + + def "matched paths produce stable server routes"() { + when: + def response = client.newCall(new Request.Builder().url(address.resolve(path).toString()).build()).execute() + assert response.code() == 200 + response.close() + TEST_WRITER.waitForTraces(1) + + then: + def serverSpan = TEST_WRITER.flatten().find { it.getTag('component')?.toString() == 'akka-http-server' } + serverSpan != null + serverSpan.getTag('http.route') == route + serverSpan.resourceName.toString() == "GET $route" + + where: + path | route + '/directive-or/literal' | '/directive-or/literal' + '/directive-or-empty/value' | '/directive-or-empty' + '/lookahead/users/alice' | '/lookahead/users/{param}' + '/lookahead-dynamic/users/alice' | '/lookahead-dynamic/users/{param}' + '/opaque/alice' | '/opaque/{param}' + '/opaque/bob' | '/opaque/{param}' + '/crud' | '/crud' + '/crud/alice' | '/crud/{param}' + '/handled-rejection/value' | '/handled-rejection/{param}' + '/delayed-rejection/value' | '/delayed-rejection/{param}' + '/success' | '/success' + '/prefix-empty-fallback' | '/prefix-empty-fallback' + '/zero-consumption-rejection/value' | '/zero-consumption-rejection' + '/directive-rejection/value?required=yes' | '/directive-rejection/{param}' + '/items/a%20b' | '/items/{param}' + '/items/a%2Fb' | '/items/{param}' + '/items/a%25b' | '/items/{param}' + '/query?some=value' | '/query' + '/injected-id/ping/1' | '/injected-id/ping/{param}' + '/injected-id/ping/2' | '/injected-id/ping/{param}' + '/injected-id/fing/3' | '/injected-id/fing/{param}' + '/route-async/4/detail/5' | '/route-async/{param}/detail/{param}' + '/route-alternative/6/accepted' | '/route-alternative/{param}/accepted' + } + + def "rejected paths do not produce server routes"() { + when: + def unmatched = client.newCall(new Request.Builder().url(address.resolve('/no-such-route').toString()).build()).execute() + assert unmatched.code() == 404 + unmatched.close() + TEST_WRITER.waitForTraces(1) + def unmatchedSpan = TEST_WRITER.flatten().find { it.getTag('component')?.toString() == 'akka-http-server' } + TEST_WRITER.clear() + def response = client.newCall(new Request.Builder().url(address.resolve(path).toString()).build()).execute() + assert response.code() == 404 + response.close() + TEST_WRITER.waitForTraces(1) + + then: + def serverSpan = TEST_WRITER.flatten().find { it.getTag('component')?.toString() == 'akka-http-server' } + serverSpan != null + serverSpan.getTag('http.route') == null + serverSpan.resourceName.toString() == unmatchedSpan.resourceName.toString() + + where: + path << ['/route-alternative/6/other', '/route-async/4/other'] + } + + def "a fallback retains only its enclosing prefix"() { + when: + def response = client.newCall(new Request.Builder().url(address.resolve(path).toString()).build()).execute() + assert response.code() == 200 + assert response.body().string() == 'fallback' + response.close() + TEST_WRITER.waitForTraces(1) + + then: + def serverSpan = TEST_WRITER.flatten().find { it.getTag('component')?.toString() == 'akka-http-server' } + serverSpan.getTag('http.route') == route + serverSpan.resourceName.toString() == "GET $route" + + where: + path | route + '/directive-rejection/value' | '/directive-rejection' + '/header-rejection/value' | '/header-rejection' + } + + def "path first CRUD keeps routes for write methods"() { + expect: + [['POST', '/crud', '/crud'], ['DELETE', '/crud/alice', '/crud/{param}']].each { method, path, route -> + def body = method == 'POST' ? okhttp3.RequestBody.create(okhttp3.MediaType.parse('text/plain'), '') : null + def response = client.newCall(new Request.Builder().url(address.resolve(path).toString()).method(method, body).build()).execute() + assert response.code() == 200 + response.close() + TEST_WRITER.waitForTraces(1) + def span = TEST_WRITER.flatten().find { it.getTag('component')?.toString() == 'akka-http-server' } + assert span.getTag('http.route') == route + assert span.resourceName.toString() == "$method $route" + TEST_WRITER.clear() + } + } + + def "concurrent requests retain their own routes across asynchronous matching and rejection"() { + given: + def counter = new AtomicInteger() + int requests = 40 + + when: + ThreadUtils.runConcurrently(8, requests, { + int id = counter.incrementAndGet() + boolean async = id % 2 == 0 + String path = async ? "/route-async/$id/detail/${id + 1}" : "/route-alternative/$id/accepted" + def response = client.newCall(new Request.Builder().url(address.resolve(path).toString()).build()).execute() + try { + assert response.code() == 200 + assert response.body().string() == (async ? "$id:${id + 1}".toString() : id.toString()) + } finally { + response.close() + } + }) + TEST_WRITER.waitForTraces(requests) + + then: + def serverSpans = TEST_WRITER.flatten().findAll { it.getTag('component')?.toString() == 'akka-http-server' } + serverSpans.size() == requests + serverSpans.every { span -> + String route = span.getTag('url.path').toString().startsWith('/route-async/') ? + '/route-async/{param}/detail/{param}' : '/route-alternative/{param}/accepted' + span.getTag('http.route') == route && span.resourceName.toString() == "GET $route" + } + } +} + +class AkkaHttpRouteOtelSemanticsForkedTest extends AkkaHttpRouteOtelSemanticsTest { +} + +class AkkaHttpRouteOtelSemanticsCompletionPriorityForkedTest extends AkkaHttpRouteOtelSemanticsTest { + @Override + void configurePreAgent() { + super.configurePreAgent() + injectSysConfig('trace.integration.scala_promise_completion_priority.enabled', 'true') + } +} + +class AkkaHttpRouteOtelSemanticsRawResourceForkedTest extends AkkaHttpRouteOtelSemanticsTest { + @Override + void configurePreAgent() { + super.configurePreAgent() + injectSysConfig(TraceInstrumentationConfig.HTTP_SERVER_RAW_RESOURCE, "true") + } +} + +class AkkaHttpRouteOtelSemanticsContextSwapForkedTest extends AkkaHttpRouteOtelSemanticsTest { + @Override + void configurePreAgent() { + super.configurePreAgent() + injectSysConfig(TraceInstrumentationConfig.LEGACY_CONTEXT_MANAGER_ENABLED, "false") + } +} + class AkkaHttpServerInstrumentationAsyncTest extends AkkaHttpServerInstrumentationTest { @Override HttpServer server() { @@ -380,3 +542,163 @@ class AkkaHttpServerInstrumentationAsyncHttp2Test extends AkkaHttpServerInstrume return new AkkaHttpTestWebServer(AkkaHttpTestWebServer.BindAndHandleAsyncHttp2()) } } + + +abstract class AkkaHttpRouteConfigurationTest extends WithHttpServer implements TestingGenericHttpNamingConventions.ServerV0 { + @Override + void configurePreAgent() { + super.configurePreAgent() + injectSysConfig(TraceInstrumentationConfig.LEGACY_CONTEXT_MANAGER_ENABLED, "true") + } + + @Override + HttpServer server() { + new AkkaHttpTestWebServer(AkkaHttpTestWebServer.BindAndHandle()) + } + + boolean routeEnabled() { + false + } + boolean otelEnabled() { + false + } + + boolean routeBasedNaming() { + true + } + + def "route opt in respects naming semantics"() { + when: + def response = client.newCall(new Request.Builder().url(address.resolve(path).toString()).build()).execute() + assert response.code() == 200 + response.close() + TEST_WRITER.waitForTraces(1) + + then: + def span = TEST_WRITER.flatten().find { it.getTag('component')?.toString() == 'akka-http-server' } + span.getTag('http.route') == (routeEnabled() ? route : null) + span.resourceName.toString() == (otelEnabled() ? 'GET' : (routeBasedNaming() ? "GET $route" : "GET $path")) + + where: + path | route + '/items/value' | '/items/{param}' + '/prefix-empty-fallback' | '/prefix-empty-fallback' + '/zero-consumption-rejection/value' | '/zero-consumption-rejection' + } + + def "rejected route leaves the original resource"() { + when: + def response = client.newCall(new Request.Builder().url(address.resolve(path).toString()).build()).execute() + assert response.code() == status + response.close() + TEST_WRITER.waitForTraces(1) + + then: + def span = TEST_WRITER.flatten().find { it.getTag('component')?.toString() == 'akka-http-server' } + span.getTag('http.route') == null + span.resourceName.toString() == (otelEnabled() ? 'GET' : (status == 404 ? '404' : "GET $path")) + + where: + path | status + '/route-async/4/other' | 404 + } +} + +class AkkaHttpRouteEnabledForkedTest extends AkkaHttpRouteConfigurationTest { + @Override + void configurePreAgent() { + super.configurePreAgent() + injectSysConfig('integration.akka-http-route.enabled', 'true') + } + + @Override + boolean routeEnabled() { + true + } +} + +class AkkaHttpRouteDisabledOtelSemanticsForkedTest extends AkkaHttpRouteConfigurationTest implements TestingOtelHttpSemantics { + @Override + void configurePreAgent() { + super.configurePreAgent() + injectSysConfig('integration.akka-http-route.enabled', 'false') + } + + @Override + boolean otelEnabled() { + true + } +} + +class AkkaHttpRouteNamingDisabledForkedTest extends AkkaHttpRouteEnabledForkedTest { + @Override + void configurePreAgent() { + super.configurePreAgent() + injectSysConfig('http.server.route-based-naming', 'false') + } + + @Override + boolean routeBasedNaming() { + false + } +} + +class AkkaHttpSingleRejectedRouteForkedTest extends WithHttpServer implements TestingGenericHttpNamingConventions.ServerV0, TestingOtelHttpSemantics { + @Override + HttpServer server() { + new AkkaHttpTestWebServer(AkkaHttpTestWebServer.BindRejectedRoute()) + } + + def "a single rejected route leaves no server route"() { + when: + def response = client.newCall(new Request.Builder().url(address.resolve('/single/value').toString()).build()).execute() + assert response.code() == 404 + response.close() + TEST_WRITER.waitForTraces(1) + + then: + def span = TEST_WRITER.flatten().find { it.getTag('component')?.toString() == 'akka-http-server' } + span.getTag('http.route') == null + span.resourceName.toString() == 'GET' + } +} + +class AkkaHttpRouteInferredProxyOtelSemanticsForkedTest extends WithHttpServer implements TestingGenericHttpNamingConventions.ServerV0, TestingOtelHttpSemantics { + @Override + protected void configurePreAgent() { + super.configurePreAgent() + injectSysConfig('trace.inferred.proxy.services.enabled', 'true') + } + + @Override + HttpServer server() { + new AkkaHttpTestWebServer(AkkaHttpTestWebServer.BindAndHandle()) + } + + def "application routes preserve the inferred gateway route"() { + when: + def response = client.newCall(new Request.Builder() + .url(address.resolve('/items/value').toString()) + .header('x-dd-proxy', 'aws-apigateway') + .header('x-dd-proxy-request-time-ms', Long.toString(System.currentTimeMillis())) + .header('x-dd-proxy-httpmethod', 'GET') + .header('x-dd-proxy-path', '/public/items/value') + .header('x-dd-proxy-resource-path', '/public/{proxy+}') + .build()).execute() + assert response.code() == 200 + response.close() + TEST_WRITER.waitForTraces(1) + + then: + def spans = TEST_WRITER.flatten() + def gateway = spans.find { it.getTag('_dd.inferred_span') != null } + def server = spans.find { it.getTag('component')?.toString() == 'akka-http-server' } + gateway != null + server != null + server.parentId == gateway.spanId + gateway.getTag('http.route') == '/public/{proxy+}' + gateway.resourceName.toString() == 'GET /public/{proxy+}' + server.getTag('http.route') == '/items/{param}' + server.resourceName.toString() == 'GET /items/{param}' + } +} diff --git a/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/src/baseTest/java/datadog/trace/instrumentation/akkahttp/AkkaHttpRouteHelperTest.java b/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/src/baseTest/java/datadog/trace/instrumentation/akkahttp/AkkaHttpRouteHelperTest.java new file mode 100644 index 00000000000..821f7562fc3 --- /dev/null +++ b/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/src/baseTest/java/datadog/trace/instrumentation/akkahttp/AkkaHttpRouteHelperTest.java @@ -0,0 +1,75 @@ +package datadog.trace.instrumentation.akkahttp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import akka.http.scaladsl.model.Uri; +import akka.http.scaladsl.server.PathMatcher; +import datadog.context.Context; +import datadog.context.ContextScope; +import org.junit.jupiter.api.Test; + +class AkkaHttpRouteHelperTest { + private static Uri.Path path(String value) { + return Uri.apply(value).path(); + } + + @Test + void failedCompositeRollsBackItsSuccessfulLeaves() { + AkkaHttpRouteState state = new AkkaHttpRouteState(path("/items/value")); + state.matched(path("/items/value"), path("items/value"), "/"); + state.matched(path("items/value"), path("/value"), "items"); + state.matched(path("/value"), path("value"), "/"); + state.unmatched(path("/items/value")); + assertNull(state.template()); + assertEquals(path("/items/value"), state.remaining); + } + + @Test + void rejectedAlternativeCannotChangeItsParent() { + AkkaHttpRouteState parent = new AkkaHttpRouteState(path("/items/value")); + parent.matched(path("/items/value"), path("/value"), "/items"); + AkkaHttpRouteState child = parent.fork(); + child.matched(path("/value"), path(""), "/{param}"); + assertEquals("/items", parent.template()); + assertEquals("/items/{param}", child.template()); + AkkaHttpRouteState fallback = parent.fork(); + parent.select(fallback); + assertEquals("/items", parent.template()); + } + + @Test + void completedAlternativePublishesItsOwnRoute() { + AkkaHttpRouteState parent = new AkkaHttpRouteState(path("/items/value")); + AkkaHttpRouteState child = parent.fork(); + child.matched(path("/items/value"), path(""), "/items/{param}"); + parent.select(child); + assertEquals("/items/{param}", parent.template()); + } + + @Test + void consumingMatchReplacesLookaheadWithoutChangingItsParent() { + AkkaHttpRouteState parent = new AkkaHttpRouteState(path("/api/items/value")); + parent.matched(path("/api/items/value"), path("/items/value"), "/api"); + parent.matched(path("/items/value"), path("/value"), "/{param}"); + AkkaHttpRouteState child = parent.fork(); + child.matched(path("/items/value"), path("items/value"), "/"); + child.matched(path("items/value"), path("/value"), "items"); + child.matched(path("/value"), path(""), "/{param}"); + assertEquals("/api/items/{param}", child.template()); + assertEquals("/api/{param}", parent.template()); + } + + @Test + void parentCompositeDoesNotDuplicateLeafContributions() { + AkkaHttpRouteState state = new AkkaHttpRouteState(path("/items")); + state.matched(path("/items"), path("items"), "/"); + state.matched(path("items"), path(""), "items"); + PathMatcher.Matched result = new PathMatcher.Matched<>(path(""), null, null); + // No matcher metadata should be accessed for a remainder already recorded by the leaves. + try (ContextScope ignored = state.storeIn(Context.root()).attach()) { + AkkaHttpRouteHelper.matched(null, path("/items"), result, null); + } + assertEquals("/items", state.template()); + } +} diff --git a/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/src/baseTest/java/datadog/trace/instrumentation/akkahttp/AkkaHttpRouteWrapperTest.java b/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/src/baseTest/java/datadog/trace/instrumentation/akkahttp/AkkaHttpRouteWrapperTest.java new file mode 100644 index 00000000000..1df580ca065 --- /dev/null +++ b/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/src/baseTest/java/datadog/trace/instrumentation/akkahttp/AkkaHttpRouteWrapperTest.java @@ -0,0 +1,77 @@ +package datadog.trace.instrumentation.akkahttp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import akka.http.scaladsl.model.Uri; +import akka.http.scaladsl.server.RequestContext; +import akka.http.scaladsl.server.RequestContextImpl; +import akka.http.scaladsl.server.RouteResult; +import datadog.context.Context; +import datadog.context.ContextScope; +import org.junit.jupiter.api.Test; +import scala.concurrent.ExecutionContextExecutor; +import scala.concurrent.Future; +import scala.concurrent.Future$; +import scala.concurrent.Promise; +import scala.concurrent.Promise$; +import scala.runtime.AbstractFunction1; + +class AkkaHttpRouteWrapperTest { + private static final ExecutionContextExecutor DIRECT = + new ExecutionContextExecutor() { + @Override + public ExecutionContextExecutor prepare() { + return this; + } + + @Override + public void execute(Runnable task) { + task.run(); + } + + @Override + public void reportFailure(Throwable failure) { + throw new AssertionError(failure); + } + }; + + @Test + void delayedRejectedAlternativeCannotEraseACompletedSibling() { + Uri.Path path = Uri.apply("/items/value").path(); + RequestContext request = new RequestContextImpl(null, path, DIRECT, null, null, null, null); + AkkaHttpRouteState parent = new AkkaHttpRouteState(path); + Promise rejected = Promise$.MODULE$.apply(); + try (ContextScope ignored = parent.storeIn(Context.root()).attach()) { + Future losing = + AkkaHttpRouteWrapper.wrap( + new AbstractFunction1>() { + @Override + public Future apply(RequestContext ctx) { + AkkaHttpRouteState.current() + .matched(path, Uri.apply("").path(), "/loser/{param}"); + return rejected.future(); + } + }) + .apply(request); + AkkaHttpRouteWrapper.wrap( + new AbstractFunction1>() { + @Override + public Future apply(RequestContext ctx) { + AkkaHttpRouteState.current() + .matched(path, Uri.apply("").path(), "/items/{param}"); + return Future$.MODULE$.successful( + new RouteResult.Complete( + (akka.http.scaladsl.model.HttpResponse) + akka.http.javadsl.model.HttpResponse.create())); + } + }) + .apply(request); + assertEquals("/items/{param}", parent.template()); + rejected.success(new RouteResult.Rejected(scala.collection.immutable.List$.MODULE$.empty())); + assertTrue(losing.value().isDefined()); + assertEquals("/items/{param}", parent.template()); + assertEquals(parent, AkkaHttpRouteState.current()); + } + } +} diff --git a/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/src/baseTest/scala/AkkaHttpTestWebServer.scala b/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/src/baseTest/scala/AkkaHttpTestWebServer.scala index 8fce57838e2..7536ad855b0 100644 --- a/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/src/baseTest/scala/AkkaHttpTestWebServer.scala +++ b/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/src/baseTest/scala/AkkaHttpTestWebServer.scala @@ -84,6 +84,16 @@ object AkkaHttpTestWebServer { ): Future[ServerBinding] } + val BindRejectedRoute: Binder = new Binder { + override def name: String = "single-rejected-route" + override def bind( + port: Int + )(implicit system: ActorSystem, materializer: Materializer): Future[ServerBinding] = { + import materializer.executionContext + Http().bindAndHandle(path("single" / Segment) { _ => reject }, "localhost", port) + } + } + val BindAndHandle: Binder = new Binder { override def name: String = "bind-and-handle" @@ -205,10 +215,22 @@ object AkkaHttpTestWebServer { }) .result() + private val opaqueSegment = new PathMatcher[Unit] { + override def apply(path: Uri.Path): PathMatcher.Matching[Unit] = path match { + case Uri.Path.Segment(_, tail) => PathMatcher.Matched(tail, ()) + case _ => PathMatcher.Unmatched + } + } + def route(implicit ec: ExecutionContext): Route = withController { handleRejections(blockingRejectionHandler) { respondWithDefaultHeader(defaultHeader) { - get { + pathPrefix("crud") { + pathEnd { concat(get { complete("list") }, post { complete("created") }) } ~ + path(Segment) { _ => + concat(get { complete("detail") }, delete { complete("deleted") }) + } + } ~ get { path(SUCCESS.relativePath) { complete( HttpResponse(status = SUCCESS.getStatus, entity = SUCCESS.getBody) @@ -241,6 +263,59 @@ object AkkaHttpTestWebServer { complete(HttpResponse(status = ERROR.getStatus, entity = ERROR.getBody)) } ~ path(EXCEPTION.relativePath) { throw new Exception(EXCEPTION.getBody) + } ~ pathPrefix("directive-rejection") { + path(Segment) { _ => + onSuccess(Future { 1 }) { _ => + parameter("required") { _ => complete("accepted") } + } + } ~ complete("fallback") + } ~ pathPrefix("header-rejection") { + path(Segment) { _ => + headerValueByName("authorization") { _ => complete("accepted") } + } ~ complete("fallback") + } ~ pathPrefix("zero-consumption-rejection") { + path(Segment) { _ => reject } ~ + rawPathPrefix(Segment.?) { _ => complete("fallback") } + } ~ pathPrefix("prefix-empty-fallback") { + path("detail") { complete("detail") } ~ pathEnd { complete("fallback") } + } ~ path("handled-rejection" / Segment) { _ => + handleRejections( + RejectionHandler + .newBuilder() + .handleAll[MissingQueryParamRejection] { _ => complete("handled") } + .result() + ) { + parameter("required") { _ => complete("accepted") } + } + } ~ pathPrefix("delayed-rejection") { + path(Segment) { _ => onSuccess(Future { Thread.sleep(10); 1 }) { _ => reject } } ~ + path(Segment) { _ => complete("accepted") } + } ~ (path("directive-or" / Segment).tfilter(_ => false) | + (path("directive-or" / "literal") & provide("literal"))) { _ => + complete("accepted") + } ~ pathPrefix("directive-or-empty") { + (path(Segment).tfilter(_ => false) | provide("fallback")) { _ => complete("fallback") } + } ~ pathPrefix("lookahead") { + pathPrefixTest("users") { + path("users" / Segment) { _ => complete("accepted") } + } + } ~ pathPrefix("lookahead-dynamic") { + pathPrefixTest(Segment) { _ => + path("users" / Segment) { _ => complete("accepted") } + } + } ~ path("opaque" / opaqueSegment) { + complete("accepted") + } ~ path("items" / Segment) { _ => + complete("item") + } ~ pathPrefix("route-async" / IntNumber) { id => + onSuccess(Future { id }) { _ => + path("detail" / IntNumber) { detail => + complete(s"$id:$detail") + } + } + } ~ pathPrefix("route-alternative") { + pathPrefix(IntNumber) { _ => reject } ~ + path(IntNumber / "accepted") { id => complete(s"$id") } } ~ pathPrefix("injected-id") { path("ping" / IntNumber) { id => val traceId = AgentTracer.activeSpan().getTraceId diff --git a/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/src/main/java/datadog/trace/instrumentation/akkahttp/AkkaHttpDirectiveInstrumentation.java b/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/src/main/java/datadog/trace/instrumentation/akkahttp/AkkaHttpDirectiveInstrumentation.java new file mode 100644 index 00000000000..77adf2798e3 --- /dev/null +++ b/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/src/main/java/datadog/trace/instrumentation/akkahttp/AkkaHttpDirectiveInstrumentation.java @@ -0,0 +1,51 @@ +package datadog.trace.instrumentation.akkahttp; + +import static datadog.trace.agent.tooling.bytebuddy.matcher.HierarchyMatchers.extendsClass; +import static datadog.trace.agent.tooling.bytebuddy.matcher.NameMatchers.named; +import static net.bytebuddy.matcher.ElementMatchers.isBridge; +import static net.bytebuddy.matcher.ElementMatchers.not; +import static net.bytebuddy.matcher.ElementMatchers.returns; +import static net.bytebuddy.matcher.ElementMatchers.takesArgument; +import static net.bytebuddy.matcher.ElementMatchers.takesArguments; + +import akka.http.scaladsl.server.RequestContext; +import akka.http.scaladsl.server.RouteResult; +import datadog.trace.agent.tooling.Instrumenter; +import net.bytebuddy.asm.Advice; +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.matcher.ElementMatcher; +import scala.Function1; +import scala.concurrent.Future; + +/** Isolates directive execution until its route result is known. */ +public final class AkkaHttpDirectiveInstrumentation + implements Instrumenter.ForTypeHierarchy, Instrumenter.HasMethodAdvice { + @Override + public String hierarchyMarkerType() { + return "akka.http.scaladsl.server.Directive"; + } + + @Override + public ElementMatcher hierarchyMatcher() { + return extendsClass(named(hierarchyMarkerType())); + } + + @Override + public void methodAdvice(MethodTransformer transformer) { + transformer.applyAdvice( + named("tapply") + .and(takesArguments(1)) + .and(takesArgument(0, named("scala.Function1"))) + .and(returns(named("scala.Function1"))) + .and(not(isBridge())), + getClass().getName() + "$RouteAdvice"); + } + + public static final class RouteAdvice { + @Advice.OnMethodExit(suppress = Throwable.class) + public static void exit( + @Advice.Return(readOnly = false) Function1> route) { + route = AkkaHttpRouteWrapper.wrap(route); + } + } +} diff --git a/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/src/main/java/datadog/trace/instrumentation/akkahttp/AkkaHttpPathMatcherInstrumentation.java b/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/src/main/java/datadog/trace/instrumentation/akkahttp/AkkaHttpPathMatcherInstrumentation.java new file mode 100644 index 00000000000..4c9554e3a5e --- /dev/null +++ b/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/src/main/java/datadog/trace/instrumentation/akkahttp/AkkaHttpPathMatcherInstrumentation.java @@ -0,0 +1,69 @@ +package datadog.trace.instrumentation.akkahttp; + +import static datadog.trace.agent.tooling.bytebuddy.matcher.HierarchyMatchers.extendsClass; +import static datadog.trace.agent.tooling.bytebuddy.matcher.NameMatchers.named; +import static net.bytebuddy.matcher.ElementMatchers.isBridge; +import static net.bytebuddy.matcher.ElementMatchers.isDeclaredBy; +import static net.bytebuddy.matcher.ElementMatchers.not; +import static net.bytebuddy.matcher.ElementMatchers.returns; +import static net.bytebuddy.matcher.ElementMatchers.takesArgument; +import static net.bytebuddy.matcher.ElementMatchers.takesArguments; + +import akka.http.scaladsl.model.Uri; +import akka.http.scaladsl.server.PathMatcher; +import datadog.trace.agent.tooling.Instrumenter; +import datadog.trace.bootstrap.InstrumentationContext; +import net.bytebuddy.asm.Advice; +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.matcher.ElementMatcher; + +/** Records literal metadata and matched route components. */ +public final class AkkaHttpPathMatcherInstrumentation + implements Instrumenter.ForTypeHierarchy, Instrumenter.HasMethodAdvice { + @Override + public String hierarchyMarkerType() { + return "akka.http.scaladsl.server.PathMatcher"; + } + + @Override + public ElementMatcher hierarchyMatcher() { + return extendsClass(named(hierarchyMarkerType())) + .or(named("akka.http.scaladsl.server.PathMatcher$")); + } + + @Override + public void methodAdvice(MethodTransformer transformer) { + transformer.applyAdvice( + named("apply") + .and(takesArguments(1)) + .and(takesArgument(0, named("akka.http.scaladsl.model.Uri$Path"))) + .and(returns(named("akka.http.scaladsl.server.PathMatcher$Matching"))) + .and(not(isBridge())), + getClass().getName() + "$MatchAdvice"); + transformer.applyAdvice( + named("apply") + .and(takesArgument(0, named("akka.http.scaladsl.model.Uri$Path"))) + .and(returns(named("akka.http.scaladsl.server.PathMatcher"))) + .and(isDeclaredBy(named("akka.http.scaladsl.server.PathMatcher$"))), + getClass().getName() + "$LiteralAdvice"); + } + + public static final class MatchAdvice { + @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class) + public static void exit( + @Advice.This PathMatcher matcher, + @Advice.Argument(0) Uri.Path path, + @Advice.Return PathMatcher.Matching matching) { + AkkaHttpRouteHelper.matched( + matcher, path, matching, InstrumentationContext.get(PathMatcher.class, String.class)); + } + } + + public static final class LiteralAdvice { + @Advice.OnMethodExit(suppress = Throwable.class) + public static void exit( + @Advice.Argument(0) Uri.Path path, @Advice.Return PathMatcher matcher) { + InstrumentationContext.get(PathMatcher.class, String.class).put(matcher, path.toString()); + } + } +} diff --git a/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/src/main/java/datadog/trace/instrumentation/akkahttp/AkkaHttpRouteHelper.java b/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/src/main/java/datadog/trace/instrumentation/akkahttp/AkkaHttpRouteHelper.java new file mode 100644 index 00000000000..9ea64263d4d --- /dev/null +++ b/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/src/main/java/datadog/trace/instrumentation/akkahttp/AkkaHttpRouteHelper.java @@ -0,0 +1,44 @@ +package datadog.trace.instrumentation.akkahttp; + +import akka.http.scaladsl.model.Uri; +import akka.http.scaladsl.server.PathMatcher; +import akka.http.scaladsl.server.PathMatchers; +import datadog.trace.bootstrap.ContextStore; + +/** Records successful leaf matches without allocating frames for matcher invocations. */ +public final class AkkaHttpRouteHelper { + private AkkaHttpRouteHelper() {} + + public static void matched( + PathMatcher matcher, + Uri.Path before, + PathMatcher.Matching result, + ContextStore prefixes) { + AkkaHttpRouteState state = AkkaHttpRouteState.current(); + if (state == null || state.remaining == null) { + return; + } + if (!(result instanceof PathMatcher.Matched)) { + state.unmatched(before); + return; + } + PathMatcher.Matched matched = (PathMatcher.Matched) result; + Uri.Path after = matched.pathRest(); + if (before.equals(after)) { + return; + } + // Most composed matchers finish at the remainder already recorded by their leaves. + if (after.equals(state.remaining)) { + return; + } + String literal = prefixes.get(matcher); + if (literal == null) { + if (matcher.getClass() == PathMatchers.Slash$.class) { + literal = "/"; + } else { + literal = before.startsWithSlash() ? "/{param}" : "{param}"; + } + } + state.matched(before, after, literal); + } +} diff --git a/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/src/main/java/datadog/trace/instrumentation/akkahttp/AkkaHttpRouteModule.java b/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/src/main/java/datadog/trace/instrumentation/akkahttp/AkkaHttpRouteModule.java new file mode 100644 index 00000000000..3c50f3f79bf --- /dev/null +++ b/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/src/main/java/datadog/trace/instrumentation/akkahttp/AkkaHttpRouteModule.java @@ -0,0 +1,38 @@ +package datadog.trace.instrumentation.akkahttp; + +import static datadog.trace.api.ConfigDefaults.DEFAULT_TRACE_OTEL_SEMANTICS_ENABLED; +import static datadog.trace.api.config.GeneralConfig.TRACE_OTEL_SEMANTICS_ENABLED; +import static java.util.Arrays.asList; +import static java.util.Collections.singletonMap; + +import com.google.auto.service.AutoService; +import datadog.trace.agent.tooling.Instrumenter; +import datadog.trace.agent.tooling.InstrumenterModule; +import datadog.trace.bootstrap.config.provider.ConfigProvider; +import java.util.List; +import java.util.Map; + +/** Captures matched routes and selects their completed directive branches. */ +@AutoService(InstrumenterModule.class) +public final class AkkaHttpRouteModule extends InstrumenterModule.Tracing { + public AkkaHttpRouteModule() { + super("akka-http-route"); + } + + @Override + protected boolean defaultEnabled() { + return super.defaultEnabled() + && ConfigProvider.getInstance() + .getBoolean(TRACE_OTEL_SEMANTICS_ENABLED, DEFAULT_TRACE_OTEL_SEMANTICS_ENABLED); + } + + @Override + public Map contextStore() { + return singletonMap("akka.http.scaladsl.server.PathMatcher", "java.lang.String"); + } + + @Override + public List typeInstrumentations() { + return asList(new AkkaHttpPathMatcherInstrumentation(), new AkkaHttpDirectiveInstrumentation()); + } +} diff --git a/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/src/main/java/datadog/trace/instrumentation/akkahttp/AkkaHttpRouteState.java b/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/src/main/java/datadog/trace/instrumentation/akkahttp/AkkaHttpRouteState.java new file mode 100644 index 00000000000..00dafd5218c --- /dev/null +++ b/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/src/main/java/datadog/trace/instrumentation/akkahttp/AkkaHttpRouteState.java @@ -0,0 +1,140 @@ +package datadog.trace.instrumentation.akkahttp; + +import static datadog.context.ContextKey.named; +import static datadog.trace.bootstrap.instrumentation.decorator.http.HttpResourceDecorator.HTTP_RESOURCE_DECORATOR; +import static java.util.Collections.singletonList; + +import akka.http.scaladsl.model.Uri; +import datadog.context.Context; +import datadog.context.ContextKey; +import datadog.trace.api.Config; +import datadog.trace.api.InstrumenterConfig; +import datadog.trace.api.KnownTags; +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import datadog.trace.bootstrap.instrumentation.api.Tags; + +/** + * Route contributions owned by one directive invocation, with immutable history shared by child + * directives. + */ +public final class AkkaHttpRouteState { + private static final boolean OTEL_SEMANTICS = Config.get().isTraceOtelSemanticsEnabled(); + private static final ContextKey KEY = named("akka-http-route"); + private static final boolean ROUTE_CAPTURE_ENABLED = + InstrumenterConfig.get() + .isIntegrationEnabled( + singletonList("akka-http-route"), + InstrumenterConfig.get().isIntegrationsEnabled() && OTEL_SEMANTICS); + + Uri.Path remaining; + Part part; + + /** Set once this directive's result is known; later work under its context updates the parent. */ + private AkkaHttpRouteState retiredTo; + + AkkaHttpRouteState(Uri.Path remaining) { + this.remaining = remaining; + } + + public static Context init(Context context, Uri.Path requestPath) { + return ROUTE_CAPTURE_ENABLED ? context.with(KEY, new AkkaHttpRouteState(requestPath)) : context; + } + + static AkkaHttpRouteState current() { + AkkaHttpRouteState state = Context.current().get(KEY); + // A fallback can run under the completion context of a directive that already finished. + while (state != null && state.retiredTo != null) { + state = state.retiredTo; + } + return state; + } + + Context storeIn(Context context) { + return context.with(KEY, this); + } + + AkkaHttpRouteState fork() { + AkkaHttpRouteState child = new AkkaHttpRouteState(remaining); + child.part = part; + return child; + } + + void select(AkkaHttpRouteState child) { + part = child.part; + remaining = child.remaining; + } + + void retire(AkkaHttpRouteState child) { + child.retiredTo = this; + } + + void matched(Uri.Path before, Uri.Path after, String literal) { + if (!before.equals(remaining)) { + // Lookahead matchers can inspect input that a later directive consumes again. + unmatched(before); + if (!before.equals(remaining)) { + return; + } + } + part = new Part(before, literal, part); + remaining = after; + } + + void unmatched(Uri.Path before) { + // A composed matcher can fail after some of its leaves have consumed input. + for (Part cursor = part; cursor != null; cursor = cursor.previous) { + if (cursor.before.equals(before)) { + part = cursor.previous; + remaining = before; + return; + } + } + } + + String template() { + if (part == null) { + return null; + } + int length = 0; + for (Part cursor = part; cursor != null; cursor = cursor.previous) { + length += cursor.literal.length(); + } + char[] chars = new char[length]; + for (Part cursor = part; cursor != null; cursor = cursor.previous) { + String literal = cursor.literal; + length -= literal.length(); + literal.getChars(0, literal.length(), chars, length); + } + return new String(chars); + } + + /** Publishes only the selected route, before normal HTTP response naming. */ + public static void onComplete(Context context, AgentSpan span) { + AkkaHttpRouteState state = context.get(KEY); + if (state == null) { + return; + } + String template = state.template(); + state.part = null; + state.remaining = null; + if (template == null) { + return; + } + Object method = + span.getTag(OTEL_SEMANTICS ? KnownTags.HTTP_METHOD_OTEL_NAME : Tags.HTTP_METHOD); + HTTP_RESOURCE_DECORATOR.withRoute( + span, method == null ? null : method.toString(), template, true); + } + + static final class Part { + final Uri.Path before; + final String literal; + final Part previous; + + Part(Uri.Path before, String literal, Part previous) { + this.before = before; + this.literal = literal; + this.previous = previous; + } + } +} diff --git a/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/src/main/java/datadog/trace/instrumentation/akkahttp/AkkaHttpRouteWrapper.java b/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/src/main/java/datadog/trace/instrumentation/akkahttp/AkkaHttpRouteWrapper.java new file mode 100644 index 00000000000..6fb077b2b2c --- /dev/null +++ b/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/src/main/java/datadog/trace/instrumentation/akkahttp/AkkaHttpRouteWrapper.java @@ -0,0 +1,75 @@ +package datadog.trace.instrumentation.akkahttp; + +import akka.http.scaladsl.server.RequestContext; +import akka.http.scaladsl.server.RouteResult; +import datadog.context.Context; +import datadog.context.ContextScope; +import scala.Function1; +import scala.Option; +import scala.concurrent.Future; +import scala.runtime.AbstractFunction1; +import scala.runtime.AbstractPartialFunction; +import scala.runtime.BoxedUnit; +import scala.util.Try; + +/** Isolates a directive invocation until its route result is known. */ +public final class AkkaHttpRouteWrapper + extends AbstractFunction1> { + private final Function1> delegate; + + private AkkaHttpRouteWrapper(Function1> delegate) { + this.delegate = delegate; + } + + public static Function1> wrap( + Function1> route) { + return route instanceof AkkaHttpRouteWrapper ? route : new AkkaHttpRouteWrapper(route); + } + + @Override + public Future apply(RequestContext request) { + AkkaHttpRouteState parent = AkkaHttpRouteState.current(); + if (parent == null) { + return delegate.apply(request); + } + AkkaHttpRouteState child = parent.fork(); + try (ContextScope ignored = child.storeIn(Context.current()).attach()) { + Future future; + try { + future = delegate.apply(request); + } catch (Throwable failure) { + parent.select(child); + parent.retire(child); + throw failure; + } + Option> value = future.value(); + if (value.isDefined()) { + select(parent, child, value.get()); + return future; + } + return future.andThen( + new AbstractPartialFunction, BoxedUnit>() { + @Override + public boolean isDefinedAt(Try result) { + return true; + } + + @Override + public BoxedUnit apply(Try result) { + select(parent, child, result); + return BoxedUnit.UNIT; + } + }, + request.executionContext()); + } + } + + private static void select( + AkkaHttpRouteState parent, AkkaHttpRouteState child, Try result) { + // Exceptions may be converted to a response by an enclosing exception handler. + if (result.isFailure() || result.get() instanceof RouteResult.Complete) { + parent.select(child); + } + parent.retire(child); + } +} diff --git a/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/src/main/java/datadog/trace/instrumentation/akkahttp/DatadogWrapperHelper.java b/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/src/main/java/datadog/trace/instrumentation/akkahttp/DatadogWrapperHelper.java index cf0a5d68258..b9d9b052274 100644 --- a/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/src/main/java/datadog/trace/instrumentation/akkahttp/DatadogWrapperHelper.java +++ b/dd-java-agent/instrumentation/akka/akka-http/akka-http-10.0/src/main/java/datadog/trace/instrumentation/akkahttp/DatadogWrapperHelper.java @@ -33,7 +33,7 @@ private static Context startSpan(final HttpRequest request) { DECORATE.afterStart(span); DECORATE.onRequest(span, request, request, parentContext); - return context; + return AkkaHttpRouteState.init(context, request.uri().path()); } static final class SwappedContextScope implements ContextScope { @@ -64,6 +64,7 @@ public void close() { public static void finishSpan(final Context context, final HttpResponse response) { final AgentSpan span = fromContext(context); + AkkaHttpRouteState.onComplete(context, span); DECORATE.onResponse(span, response); DECORATE.beforeFinish(context); @@ -72,6 +73,7 @@ public static void finishSpan(final Context context, final HttpResponse response public static void finishSpan(final Context context, final Throwable t) { final AgentSpan span = fromContext(context); + AkkaHttpRouteState.onComplete(context, span); DECORATE.onError(span, t); span.setHttpStatusCode(500); DECORATE.beforeFinish(context); diff --git a/dd-java-agent/instrumentation/pekko/pekko-http-1.0/src/baseTest/groovy/PekkoHttpServerInstrumentationTest.groovy b/dd-java-agent/instrumentation/pekko/pekko-http-1.0/src/baseTest/groovy/PekkoHttpServerInstrumentationTest.groovy index 1034cf83e26..0e67ac7dd02 100644 --- a/dd-java-agent/instrumentation/pekko/pekko-http-1.0/src/baseTest/groovy/PekkoHttpServerInstrumentationTest.groovy +++ b/dd-java-agent/instrumentation/pekko/pekko-http-1.0/src/baseTest/groovy/PekkoHttpServerInstrumentationTest.groovy @@ -1,6 +1,8 @@ import datadog.trace.agent.test.base.HttpServer import datadog.trace.agent.test.base.HttpServerTest +import datadog.trace.agent.test.base.WithHttpServer import datadog.trace.agent.test.naming.TestingGenericHttpNamingConventions +import datadog.trace.agent.test.naming.TestingOtelHttpSemantics import datadog.trace.api.config.TraceInstrumentationConfig import datadog.trace.test.util.ThreadUtils import datadog.trace.instrumentation.pekkohttp.PekkoHttpServerDecorator @@ -108,6 +110,167 @@ class PekkoHttpServerInstrumentationSyncV0ForkedTest extends PekkoHttpServerInst class PekkoHttpServerInstrumentationSyncV1ForkedTest extends PekkoHttpServerInstrumentationSyncTest implements TestingGenericHttpNamingConventions.ServerV1 { } +abstract class PekkoHttpRouteOtelSemanticsTest extends WithHttpServer implements TestingGenericHttpNamingConventions.ServerV0, TestingOtelHttpSemantics { + @Override + HttpServer server() { + new PekkoHttpTestWebServer(PekkoHttpTestWebServer.BindAndHandle()) + } + + def "matched paths produce stable server routes"() { + when: + def response = client.newCall(new Request.Builder().url(address.resolve(path).toString()).build()).execute() + assert response.code() == 200 + response.close() + TEST_WRITER.waitForTraces(1) + + then: + def serverSpan = TEST_WRITER.flatten().find { it.getTag('component')?.toString() == 'pekko-http-server' } + serverSpan != null + serverSpan.getTag('http.route') == route + serverSpan.resourceName.toString() == "GET $route" + + where: + path | route + '/directive-or/literal' | '/directive-or/literal' + '/directive-or-empty/value' | '/directive-or-empty' + '/lookahead/users/alice' | '/lookahead/users/{param}' + '/lookahead-dynamic/users/alice' | '/lookahead-dynamic/users/{param}' + '/opaque/alice' | '/opaque/{param}' + '/opaque/bob' | '/opaque/{param}' + '/crud' | '/crud' + '/crud/alice' | '/crud/{param}' + '/handled-rejection/value' | '/handled-rejection/{param}' + '/delayed-rejection/value' | '/delayed-rejection/{param}' + '/success' | '/success' + '/prefix-empty-fallback' | '/prefix-empty-fallback' + '/zero-consumption-rejection/value' | '/zero-consumption-rejection' + '/directive-rejection/value?required=yes' | '/directive-rejection/{param}' + '/items/a%20b' | '/items/{param}' + '/items/a%2Fb' | '/items/{param}' + '/items/a%25b' | '/items/{param}' + '/query?some=value' | '/query' + '/injected-id/ping/1' | '/injected-id/ping/{param}' + '/injected-id/ping/2' | '/injected-id/ping/{param}' + '/injected-id/fing/3' | '/injected-id/fing/{param}' + '/route-async/4/detail/5' | '/route-async/{param}/detail/{param}' + '/route-alternative/6/accepted' | '/route-alternative/{param}/accepted' + } + + def "rejected paths do not produce server routes"() { + when: + def unmatched = client.newCall(new Request.Builder().url(address.resolve('/no-such-route').toString()).build()).execute() + assert unmatched.code() == 404 + unmatched.close() + TEST_WRITER.waitForTraces(1) + def unmatchedSpan = TEST_WRITER.flatten().find { it.getTag('component')?.toString() == 'pekko-http-server' } + TEST_WRITER.clear() + def response = client.newCall(new Request.Builder().url(address.resolve(path).toString()).build()).execute() + assert response.code() == 404 + response.close() + TEST_WRITER.waitForTraces(1) + + then: + def serverSpan = TEST_WRITER.flatten().find { it.getTag('component')?.toString() == 'pekko-http-server' } + serverSpan != null + serverSpan.getTag('http.route') == null + serverSpan.resourceName.toString() == unmatchedSpan.resourceName.toString() + + where: + path << ['/route-alternative/6/other', '/route-async/4/other'] + } + + def "a fallback retains only its enclosing prefix"() { + when: + def response = client.newCall(new Request.Builder().url(address.resolve(path).toString()).build()).execute() + assert response.code() == 200 + assert response.body().string() == 'fallback' + response.close() + TEST_WRITER.waitForTraces(1) + + then: + def serverSpan = TEST_WRITER.flatten().find { it.getTag('component')?.toString() == 'pekko-http-server' } + serverSpan.getTag('http.route') == route + serverSpan.resourceName.toString() == "GET $route" + + where: + path | route + '/directive-rejection/value' | '/directive-rejection' + '/header-rejection/value' | '/header-rejection' + } + + def "path first CRUD keeps routes for write methods"() { + expect: + [['POST', '/crud', '/crud'], ['DELETE', '/crud/alice', '/crud/{param}']].each { method, path, route -> + def body = method == 'POST' ? okhttp3.RequestBody.create(okhttp3.MediaType.parse('text/plain'), '') : null + def response = client.newCall(new Request.Builder().url(address.resolve(path).toString()).method(method, body).build()).execute() + assert response.code() == 200 + response.close() + TEST_WRITER.waitForTraces(1) + def span = TEST_WRITER.flatten().find { it.getTag('component')?.toString() == 'pekko-http-server' } + assert span.getTag('http.route') == route + assert span.resourceName.toString() == "$method $route" + TEST_WRITER.clear() + } + } + + def "concurrent requests retain their own routes across asynchronous matching and rejection"() { + given: + def counter = new AtomicInteger() + int requests = 40 + + when: + ThreadUtils.runConcurrently(8, requests, { + int id = counter.incrementAndGet() + boolean async = id % 2 == 0 + String path = async ? "/route-async/$id/detail/${id + 1}" : "/route-alternative/$id/accepted" + def response = client.newCall(new Request.Builder().url(address.resolve(path).toString()).build()).execute() + try { + assert response.code() == 200 + assert response.body().string() == (async ? "$id:${id + 1}".toString() : id.toString()) + } finally { + response.close() + } + }) + TEST_WRITER.waitForTraces(requests) + + then: + def serverSpans = TEST_WRITER.flatten().findAll { it.getTag('component')?.toString() == 'pekko-http-server' } + serverSpans.size() == requests + serverSpans.every { span -> + String route = span.getTag('url.path').toString().startsWith('/route-async/') ? + '/route-async/{param}/detail/{param}' : '/route-alternative/{param}/accepted' + span.getTag('http.route') == route && span.resourceName.toString() == "GET $route" + } + } +} + +class PekkoHttpRouteOtelSemanticsForkedTest extends PekkoHttpRouteOtelSemanticsTest { +} + +class PekkoHttpRouteOtelSemanticsCompletionPriorityForkedTest extends PekkoHttpRouteOtelSemanticsTest { + @Override + void configurePreAgent() { + super.configurePreAgent() + injectSysConfig('trace.integration.scala_promise_completion_priority.enabled', 'true') + } +} + +class PekkoHttpRouteOtelSemanticsRawResourceForkedTest extends PekkoHttpRouteOtelSemanticsTest { + @Override + void configurePreAgent() { + super.configurePreAgent() + injectSysConfig(TraceInstrumentationConfig.HTTP_SERVER_RAW_RESOURCE, "true") + } +} + +class PekkoHttpRouteOtelSemanticsContextSwapForkedTest extends PekkoHttpRouteOtelSemanticsTest { + @Override + void configurePreAgent() { + super.configurePreAgent() + injectSysConfig(TraceInstrumentationConfig.LEGACY_CONTEXT_MANAGER_ENABLED, "false") + } +} + class PekkoHttpServerInstrumentationAsyncTest extends PekkoHttpServerInstrumentationTest { @Override HttpServer server() { @@ -159,3 +322,163 @@ class PekkoHttpServerInstrumentationAsyncHttp2Test extends PekkoHttpServerInstru return new PekkoHttpTestWebServer(PekkoHttpTestWebServer.BindAndHandleAsyncHttp2()) } } + + +abstract class PekkoHttpRouteConfigurationTest extends WithHttpServer implements TestingGenericHttpNamingConventions.ServerV0 { + @Override + void configurePreAgent() { + super.configurePreAgent() + injectSysConfig(TraceInstrumentationConfig.LEGACY_CONTEXT_MANAGER_ENABLED, "true") + } + + @Override + HttpServer server() { + new PekkoHttpTestWebServer(PekkoHttpTestWebServer.BindAndHandle()) + } + + boolean routeEnabled() { + false + } + boolean otelEnabled() { + false + } + + boolean routeBasedNaming() { + true + } + + def "route opt in respects naming semantics"() { + when: + def response = client.newCall(new Request.Builder().url(address.resolve(path).toString()).build()).execute() + assert response.code() == 200 + response.close() + TEST_WRITER.waitForTraces(1) + + then: + def span = TEST_WRITER.flatten().find { it.getTag('component')?.toString() == 'pekko-http-server' } + span.getTag('http.route') == (routeEnabled() ? route : null) + span.resourceName.toString() == (otelEnabled() ? 'GET' : (routeBasedNaming() ? "GET $route" : "GET $path")) + + where: + path | route + '/items/value' | '/items/{param}' + '/prefix-empty-fallback' | '/prefix-empty-fallback' + '/zero-consumption-rejection/value' | '/zero-consumption-rejection' + } + + def "rejected route leaves the original resource"() { + when: + def response = client.newCall(new Request.Builder().url(address.resolve(path).toString()).build()).execute() + assert response.code() == status + response.close() + TEST_WRITER.waitForTraces(1) + + then: + def span = TEST_WRITER.flatten().find { it.getTag('component')?.toString() == 'pekko-http-server' } + span.getTag('http.route') == null + span.resourceName.toString() == (otelEnabled() ? 'GET' : (status == 404 ? '404' : "GET $path")) + + where: + path | status + '/route-async/4/other' | 404 + } +} + +class PekkoHttpRouteEnabledForkedTest extends PekkoHttpRouteConfigurationTest { + @Override + void configurePreAgent() { + super.configurePreAgent() + injectSysConfig('integration.pekko-http-route.enabled', 'true') + } + + @Override + boolean routeEnabled() { + true + } +} + +class PekkoHttpRouteDisabledOtelSemanticsForkedTest extends PekkoHttpRouteConfigurationTest implements TestingOtelHttpSemantics { + @Override + void configurePreAgent() { + super.configurePreAgent() + injectSysConfig('integration.pekko-http-route.enabled', 'false') + } + + @Override + boolean otelEnabled() { + true + } +} + +class PekkoHttpRouteNamingDisabledForkedTest extends PekkoHttpRouteEnabledForkedTest { + @Override + void configurePreAgent() { + super.configurePreAgent() + injectSysConfig('http.server.route-based-naming', 'false') + } + + @Override + boolean routeBasedNaming() { + false + } +} + +class PekkoHttpSingleRejectedRouteForkedTest extends WithHttpServer implements TestingGenericHttpNamingConventions.ServerV0, TestingOtelHttpSemantics { + @Override + HttpServer server() { + new PekkoHttpTestWebServer(PekkoHttpTestWebServer.BindRejectedRoute()) + } + + def "a single rejected route leaves no server route"() { + when: + def response = client.newCall(new Request.Builder().url(address.resolve('/single/value').toString()).build()).execute() + assert response.code() == 404 + response.close() + TEST_WRITER.waitForTraces(1) + + then: + def span = TEST_WRITER.flatten().find { it.getTag('component')?.toString() == 'pekko-http-server' } + span.getTag('http.route') == null + span.resourceName.toString() == 'GET' + } +} + +class PekkoHttpRouteInferredProxyOtelSemanticsForkedTest extends WithHttpServer implements TestingGenericHttpNamingConventions.ServerV0, TestingOtelHttpSemantics { + @Override + protected void configurePreAgent() { + super.configurePreAgent() + injectSysConfig('trace.inferred.proxy.services.enabled', 'true') + } + + @Override + HttpServer server() { + new PekkoHttpTestWebServer(PekkoHttpTestWebServer.BindAndHandle()) + } + + def "application routes preserve the inferred gateway route"() { + when: + def response = client.newCall(new Request.Builder() + .url(address.resolve('/items/value').toString()) + .header('x-dd-proxy', 'aws-apigateway') + .header('x-dd-proxy-request-time-ms', Long.toString(System.currentTimeMillis())) + .header('x-dd-proxy-httpmethod', 'GET') + .header('x-dd-proxy-path', '/public/items/value') + .header('x-dd-proxy-resource-path', '/public/{proxy+}') + .build()).execute() + assert response.code() == 200 + response.close() + TEST_WRITER.waitForTraces(1) + + then: + def spans = TEST_WRITER.flatten() + def gateway = spans.find { it.getTag('_dd.inferred_span') != null } + def server = spans.find { it.getTag('component')?.toString() == 'pekko-http-server' } + gateway != null + server != null + server.parentId == gateway.spanId + gateway.getTag('http.route') == '/public/{proxy+}' + gateway.resourceName.toString() == 'GET /public/{proxy+}' + server.getTag('http.route') == '/items/{param}' + server.resourceName.toString() == 'GET /items/{param}' + } +} diff --git a/dd-java-agent/instrumentation/pekko/pekko-http-1.0/src/baseTest/java/datadog/trace/instrumentation/pekkohttp/PekkoHttpRouteHelperTest.java b/dd-java-agent/instrumentation/pekko/pekko-http-1.0/src/baseTest/java/datadog/trace/instrumentation/pekkohttp/PekkoHttpRouteHelperTest.java new file mode 100644 index 00000000000..56752ca7975 --- /dev/null +++ b/dd-java-agent/instrumentation/pekko/pekko-http-1.0/src/baseTest/java/datadog/trace/instrumentation/pekkohttp/PekkoHttpRouteHelperTest.java @@ -0,0 +1,75 @@ +package datadog.trace.instrumentation.pekkohttp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import datadog.context.Context; +import datadog.context.ContextScope; +import org.apache.pekko.http.scaladsl.model.Uri; +import org.apache.pekko.http.scaladsl.server.PathMatcher; +import org.junit.jupiter.api.Test; + +class PekkoHttpRouteHelperTest { + private static Uri.Path path(String value) { + return Uri.apply(value).path(); + } + + @Test + void failedCompositeRollsBackItsSuccessfulLeaves() { + PekkoHttpRouteState state = new PekkoHttpRouteState(path("/items/value")); + state.matched(path("/items/value"), path("items/value"), "/"); + state.matched(path("items/value"), path("/value"), "items"); + state.matched(path("/value"), path("value"), "/"); + state.unmatched(path("/items/value")); + assertNull(state.template()); + assertEquals(path("/items/value"), state.remaining); + } + + @Test + void rejectedAlternativeCannotChangeItsParent() { + PekkoHttpRouteState parent = new PekkoHttpRouteState(path("/items/value")); + parent.matched(path("/items/value"), path("/value"), "/items"); + PekkoHttpRouteState child = parent.fork(); + child.matched(path("/value"), path(""), "/{param}"); + assertEquals("/items", parent.template()); + assertEquals("/items/{param}", child.template()); + PekkoHttpRouteState fallback = parent.fork(); + parent.select(fallback); + assertEquals("/items", parent.template()); + } + + @Test + void completedAlternativePublishesItsOwnRoute() { + PekkoHttpRouteState parent = new PekkoHttpRouteState(path("/items/value")); + PekkoHttpRouteState child = parent.fork(); + child.matched(path("/items/value"), path(""), "/items/{param}"); + parent.select(child); + assertEquals("/items/{param}", parent.template()); + } + + @Test + void consumingMatchReplacesLookaheadWithoutChangingItsParent() { + PekkoHttpRouteState parent = new PekkoHttpRouteState(path("/api/items/value")); + parent.matched(path("/api/items/value"), path("/items/value"), "/api"); + parent.matched(path("/items/value"), path("/value"), "/{param}"); + PekkoHttpRouteState child = parent.fork(); + child.matched(path("/items/value"), path("items/value"), "/"); + child.matched(path("items/value"), path("/value"), "items"); + child.matched(path("/value"), path(""), "/{param}"); + assertEquals("/api/items/{param}", child.template()); + assertEquals("/api/{param}", parent.template()); + } + + @Test + void parentCompositeDoesNotDuplicateLeafContributions() { + PekkoHttpRouteState state = new PekkoHttpRouteState(path("/items")); + state.matched(path("/items"), path("items"), "/"); + state.matched(path("items"), path(""), "items"); + PathMatcher.Matched result = new PathMatcher.Matched<>(path(""), null, null); + // No matcher metadata should be accessed for a remainder already recorded by the leaves. + try (ContextScope ignored = state.storeIn(Context.root()).attach()) { + PekkoHttpRouteHelper.matched(null, path("/items"), result, null); + } + assertEquals("/items", state.template()); + } +} diff --git a/dd-java-agent/instrumentation/pekko/pekko-http-1.0/src/baseTest/java/datadog/trace/instrumentation/pekkohttp/PekkoHttpRouteWrapperTest.java b/dd-java-agent/instrumentation/pekko/pekko-http-1.0/src/baseTest/java/datadog/trace/instrumentation/pekkohttp/PekkoHttpRouteWrapperTest.java new file mode 100644 index 00000000000..1449a3897f6 --- /dev/null +++ b/dd-java-agent/instrumentation/pekko/pekko-http-1.0/src/baseTest/java/datadog/trace/instrumentation/pekkohttp/PekkoHttpRouteWrapperTest.java @@ -0,0 +1,77 @@ +package datadog.trace.instrumentation.pekkohttp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.context.Context; +import datadog.context.ContextScope; +import org.apache.pekko.http.scaladsl.model.Uri; +import org.apache.pekko.http.scaladsl.server.RequestContext; +import org.apache.pekko.http.scaladsl.server.RequestContextImpl; +import org.apache.pekko.http.scaladsl.server.RouteResult; +import org.junit.jupiter.api.Test; +import scala.concurrent.ExecutionContextExecutor; +import scala.concurrent.Future; +import scala.concurrent.Future$; +import scala.concurrent.Promise; +import scala.concurrent.Promise$; +import scala.runtime.AbstractFunction1; + +class PekkoHttpRouteWrapperTest { + private static final ExecutionContextExecutor DIRECT = + new ExecutionContextExecutor() { + @Override + public ExecutionContextExecutor prepare() { + return this; + } + + @Override + public void execute(Runnable task) { + task.run(); + } + + @Override + public void reportFailure(Throwable failure) { + throw new AssertionError(failure); + } + }; + + @Test + void delayedRejectedAlternativeCannotEraseACompletedSibling() { + Uri.Path path = Uri.apply("/items/value").path(); + RequestContext request = new RequestContextImpl(null, path, DIRECT, null, null, null, null); + PekkoHttpRouteState parent = new PekkoHttpRouteState(path); + Promise rejected = Promise$.MODULE$.apply(); + try (ContextScope ignored = parent.storeIn(Context.root()).attach()) { + Future losing = + PekkoHttpRouteWrapper.wrap( + new AbstractFunction1>() { + @Override + public Future apply(RequestContext ctx) { + PekkoHttpRouteState.current() + .matched(path, Uri.apply("").path(), "/loser/{param}"); + return rejected.future(); + } + }) + .apply(request); + PekkoHttpRouteWrapper.wrap( + new AbstractFunction1>() { + @Override + public Future apply(RequestContext ctx) { + PekkoHttpRouteState.current() + .matched(path, Uri.apply("").path(), "/items/{param}"); + return Future$.MODULE$.successful( + new RouteResult.Complete( + (org.apache.pekko.http.scaladsl.model.HttpResponse) + org.apache.pekko.http.javadsl.model.HttpResponse.create())); + } + }) + .apply(request); + assertEquals("/items/{param}", parent.template()); + rejected.success(new RouteResult.Rejected(scala.collection.immutable.List$.MODULE$.empty())); + assertTrue(losing.value().isDefined()); + assertEquals("/items/{param}", parent.template()); + assertEquals(parent, PekkoHttpRouteState.current()); + } + } +} diff --git a/dd-java-agent/instrumentation/pekko/pekko-http-1.0/src/baseTest/scala/PekkoHttpTestWebServer.scala b/dd-java-agent/instrumentation/pekko/pekko-http-1.0/src/baseTest/scala/PekkoHttpTestWebServer.scala index 7e070ac16a5..fc8975c6a15 100644 --- a/dd-java-agent/instrumentation/pekko/pekko-http-1.0/src/baseTest/scala/PekkoHttpTestWebServer.scala +++ b/dd-java-agent/instrumentation/pekko/pekko-http-1.0/src/baseTest/scala/PekkoHttpTestWebServer.scala @@ -69,6 +69,16 @@ object PekkoHttpTestWebServer { ): Future[ServerBinding] } + val BindRejectedRoute: Binder = new Binder { + override def name: String = "single-rejected-route" + override def bind( + port: Int + )(implicit system: ActorSystem, materializer: Materializer): Future[ServerBinding] = { + import materializer.executionContext + Http().bindAndHandle(path("single" / Segment) { _ => reject }, "localhost", port) + } + } + val BindAndHandle: Binder = new Binder { override def name: String = "bind-and-handle" @@ -177,9 +187,19 @@ object PekkoHttpTestWebServer { private val defaultHeader = RawHeader(HttpServerTest.getIG_RESPONSE_HEADER, HttpServerTest.getIG_RESPONSE_HEADER_VALUE) + private val opaqueSegment = new PathMatcher[Unit] { + override def apply(path: Uri.Path): PathMatcher.Matching[Unit] = path match { + case Uri.Path.Segment(_, tail) => PathMatcher.Matched(tail, ()) + case _ => PathMatcher.Unmatched + } + } + def route(implicit ec: ExecutionContext): Route = withController { respondWithDefaultHeader(defaultHeader) { - get { + pathPrefix("crud") { + pathEnd { concat(get { complete("list") }, post { complete("created") }) } ~ + path(Segment) { _ => concat(get { complete("detail") }, delete { complete("deleted") }) } + } ~ get { path(SUCCESS.relativePath) { complete( HttpResponse(status = SUCCESS.getStatus, entity = SUCCESS.getBody) @@ -207,6 +227,59 @@ object PekkoHttpTestWebServer { complete(HttpResponse(status = ERROR.getStatus, entity = ERROR.getBody)) } ~ path(EXCEPTION.relativePath) { throw new Exception(EXCEPTION.getBody) + } ~ pathPrefix("directive-rejection") { + path(Segment) { _ => + onSuccess(Future { 1 }) { _ => + parameter("required") { _ => complete("accepted") } + } + } ~ complete("fallback") + } ~ pathPrefix("header-rejection") { + path(Segment) { _ => + headerValueByName("authorization") { _ => complete("accepted") } + } ~ complete("fallback") + } ~ pathPrefix("zero-consumption-rejection") { + path(Segment) { _ => reject } ~ + rawPathPrefix(Segment.?) { _ => complete("fallback") } + } ~ pathPrefix("prefix-empty-fallback") { + path("detail") { complete("detail") } ~ pathEnd { complete("fallback") } + } ~ path("handled-rejection" / Segment) { _ => + handleRejections( + RejectionHandler + .newBuilder() + .handleAll[MissingQueryParamRejection] { _ => complete("handled") } + .result() + ) { + parameter("required") { _ => complete("accepted") } + } + } ~ pathPrefix("delayed-rejection") { + path(Segment) { _ => onSuccess(Future { Thread.sleep(10); 1 }) { _ => reject } } ~ + path(Segment) { _ => complete("accepted") } + } ~ (path("directive-or" / Segment).tfilter(_ => false) | + (path("directive-or" / "literal") & provide("literal"))) { _ => + complete("accepted") + } ~ pathPrefix("directive-or-empty") { + (path(Segment).tfilter(_ => false) | provide("fallback")) { _ => complete("fallback") } + } ~ pathPrefix("lookahead") { + pathPrefixTest("users") { + path("users" / Segment) { _ => complete("accepted") } + } + } ~ pathPrefix("lookahead-dynamic") { + pathPrefixTest(Segment) { _ => + path("users" / Segment) { _ => complete("accepted") } + } + } ~ path("opaque" / opaqueSegment) { + complete("accepted") + } ~ path("items" / Segment) { _ => + complete("item") + } ~ pathPrefix("route-async" / IntNumber) { id => + onSuccess(Future { id }) { _ => + path("detail" / IntNumber) { detail => + complete(s"$id:$detail") + } + } + } ~ pathPrefix("route-alternative") { + pathPrefix(IntNumber) { _ => reject } ~ + path(IntNumber / "accepted") { id => complete(s"$id") } } ~ pathPrefix("injected-id") { path("ping" / IntNumber) { id => val traceId = AgentTracer.activeSpan().getTraceId diff --git a/dd-java-agent/instrumentation/pekko/pekko-http-1.0/src/main/java/datadog/trace/instrumentation/pekkohttp/DatadogWrapperHelper.java b/dd-java-agent/instrumentation/pekko/pekko-http-1.0/src/main/java/datadog/trace/instrumentation/pekkohttp/DatadogWrapperHelper.java index 7766d5018e6..93ae8d984f6 100644 --- a/dd-java-agent/instrumentation/pekko/pekko-http-1.0/src/main/java/datadog/trace/instrumentation/pekkohttp/DatadogWrapperHelper.java +++ b/dd-java-agent/instrumentation/pekko/pekko-http-1.0/src/main/java/datadog/trace/instrumentation/pekkohttp/DatadogWrapperHelper.java @@ -33,7 +33,7 @@ private static Context startSpan(final HttpRequest request) { DECORATE.afterStart(span); DECORATE.onRequest(span, request, request, parentContext); - return context; + return PekkoHttpRouteState.init(context, request.uri().path()); } static final class SwappedContextScope implements ContextScope { @@ -64,6 +64,7 @@ public void close() { public static void finishSpan(final Context context, final HttpResponse response) { final AgentSpan span = fromContext(context); + PekkoHttpRouteState.onComplete(context, span); try { DECORATE.onResponse(span, response); DECORATE.beforeFinish(context); @@ -74,6 +75,7 @@ public static void finishSpan(final Context context, final HttpResponse response public static void finishSpan(final Context context, final Throwable t) { final AgentSpan span = fromContext(context); + PekkoHttpRouteState.onComplete(context, span); try { DECORATE.onError(span, t); span.setHttpStatusCode(500); diff --git a/dd-java-agent/instrumentation/pekko/pekko-http-1.0/src/main/java/datadog/trace/instrumentation/pekkohttp/PekkoHttpDirectiveInstrumentation.java b/dd-java-agent/instrumentation/pekko/pekko-http-1.0/src/main/java/datadog/trace/instrumentation/pekkohttp/PekkoHttpDirectiveInstrumentation.java new file mode 100644 index 00000000000..65925ef21fb --- /dev/null +++ b/dd-java-agent/instrumentation/pekko/pekko-http-1.0/src/main/java/datadog/trace/instrumentation/pekkohttp/PekkoHttpDirectiveInstrumentation.java @@ -0,0 +1,51 @@ +package datadog.trace.instrumentation.pekkohttp; + +import static datadog.trace.agent.tooling.bytebuddy.matcher.HierarchyMatchers.extendsClass; +import static datadog.trace.agent.tooling.bytebuddy.matcher.NameMatchers.named; +import static net.bytebuddy.matcher.ElementMatchers.isBridge; +import static net.bytebuddy.matcher.ElementMatchers.not; +import static net.bytebuddy.matcher.ElementMatchers.returns; +import static net.bytebuddy.matcher.ElementMatchers.takesArgument; +import static net.bytebuddy.matcher.ElementMatchers.takesArguments; + +import datadog.trace.agent.tooling.Instrumenter; +import net.bytebuddy.asm.Advice; +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.matcher.ElementMatcher; +import org.apache.pekko.http.scaladsl.server.RequestContext; +import org.apache.pekko.http.scaladsl.server.RouteResult; +import scala.Function1; +import scala.concurrent.Future; + +/** Isolates directive execution until its route result is known. */ +public final class PekkoHttpDirectiveInstrumentation + implements Instrumenter.ForTypeHierarchy, Instrumenter.HasMethodAdvice { + @Override + public String hierarchyMarkerType() { + return "org.apache.pekko.http.scaladsl.server.Directive"; + } + + @Override + public ElementMatcher hierarchyMatcher() { + return extendsClass(named(hierarchyMarkerType())); + } + + @Override + public void methodAdvice(MethodTransformer transformer) { + transformer.applyAdvice( + named("tapply") + .and(takesArguments(1)) + .and(takesArgument(0, named("scala.Function1"))) + .and(returns(named("scala.Function1"))) + .and(not(isBridge())), + getClass().getName() + "$RouteAdvice"); + } + + public static final class RouteAdvice { + @Advice.OnMethodExit(suppress = Throwable.class) + public static void exit( + @Advice.Return(readOnly = false) Function1> route) { + route = PekkoHttpRouteWrapper.wrap(route); + } + } +} diff --git a/dd-java-agent/instrumentation/pekko/pekko-http-1.0/src/main/java/datadog/trace/instrumentation/pekkohttp/PekkoHttpPathMatcherInstrumentation.java b/dd-java-agent/instrumentation/pekko/pekko-http-1.0/src/main/java/datadog/trace/instrumentation/pekkohttp/PekkoHttpPathMatcherInstrumentation.java new file mode 100644 index 00000000000..0ea8fd990b7 --- /dev/null +++ b/dd-java-agent/instrumentation/pekko/pekko-http-1.0/src/main/java/datadog/trace/instrumentation/pekkohttp/PekkoHttpPathMatcherInstrumentation.java @@ -0,0 +1,69 @@ +package datadog.trace.instrumentation.pekkohttp; + +import static datadog.trace.agent.tooling.bytebuddy.matcher.HierarchyMatchers.extendsClass; +import static datadog.trace.agent.tooling.bytebuddy.matcher.NameMatchers.named; +import static net.bytebuddy.matcher.ElementMatchers.isBridge; +import static net.bytebuddy.matcher.ElementMatchers.isDeclaredBy; +import static net.bytebuddy.matcher.ElementMatchers.not; +import static net.bytebuddy.matcher.ElementMatchers.returns; +import static net.bytebuddy.matcher.ElementMatchers.takesArgument; +import static net.bytebuddy.matcher.ElementMatchers.takesArguments; + +import datadog.trace.agent.tooling.Instrumenter; +import datadog.trace.bootstrap.InstrumentationContext; +import net.bytebuddy.asm.Advice; +import net.bytebuddy.description.type.TypeDescription; +import net.bytebuddy.matcher.ElementMatcher; +import org.apache.pekko.http.scaladsl.model.Uri; +import org.apache.pekko.http.scaladsl.server.PathMatcher; + +/** Records literal metadata and matched route components. */ +public final class PekkoHttpPathMatcherInstrumentation + implements Instrumenter.ForTypeHierarchy, Instrumenter.HasMethodAdvice { + @Override + public String hierarchyMarkerType() { + return "org.apache.pekko.http.scaladsl.server.PathMatcher"; + } + + @Override + public ElementMatcher hierarchyMatcher() { + return extendsClass(named(hierarchyMarkerType())) + .or(named("org.apache.pekko.http.scaladsl.server.PathMatcher$")); + } + + @Override + public void methodAdvice(MethodTransformer transformer) { + transformer.applyAdvice( + named("apply") + .and(takesArguments(1)) + .and(takesArgument(0, named("org.apache.pekko.http.scaladsl.model.Uri$Path"))) + .and(returns(named("org.apache.pekko.http.scaladsl.server.PathMatcher$Matching"))) + .and(not(isBridge())), + getClass().getName() + "$MatchAdvice"); + transformer.applyAdvice( + named("apply") + .and(takesArgument(0, named("org.apache.pekko.http.scaladsl.model.Uri$Path"))) + .and(returns(named("org.apache.pekko.http.scaladsl.server.PathMatcher"))) + .and(isDeclaredBy(named("org.apache.pekko.http.scaladsl.server.PathMatcher$"))), + getClass().getName() + "$LiteralAdvice"); + } + + public static final class MatchAdvice { + @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class) + public static void exit( + @Advice.This PathMatcher matcher, + @Advice.Argument(0) Uri.Path path, + @Advice.Return PathMatcher.Matching matching) { + PekkoHttpRouteHelper.matched( + matcher, path, matching, InstrumentationContext.get(PathMatcher.class, String.class)); + } + } + + public static final class LiteralAdvice { + @Advice.OnMethodExit(suppress = Throwable.class) + public static void exit( + @Advice.Argument(0) Uri.Path path, @Advice.Return PathMatcher matcher) { + InstrumentationContext.get(PathMatcher.class, String.class).put(matcher, path.toString()); + } + } +} diff --git a/dd-java-agent/instrumentation/pekko/pekko-http-1.0/src/main/java/datadog/trace/instrumentation/pekkohttp/PekkoHttpRouteHelper.java b/dd-java-agent/instrumentation/pekko/pekko-http-1.0/src/main/java/datadog/trace/instrumentation/pekkohttp/PekkoHttpRouteHelper.java new file mode 100644 index 00000000000..979f3b6c815 --- /dev/null +++ b/dd-java-agent/instrumentation/pekko/pekko-http-1.0/src/main/java/datadog/trace/instrumentation/pekkohttp/PekkoHttpRouteHelper.java @@ -0,0 +1,44 @@ +package datadog.trace.instrumentation.pekkohttp; + +import datadog.trace.bootstrap.ContextStore; +import org.apache.pekko.http.scaladsl.model.Uri; +import org.apache.pekko.http.scaladsl.server.PathMatcher; +import org.apache.pekko.http.scaladsl.server.PathMatchers; + +/** Records successful leaf matches without allocating frames for matcher invocations. */ +public final class PekkoHttpRouteHelper { + private PekkoHttpRouteHelper() {} + + public static void matched( + PathMatcher matcher, + Uri.Path before, + PathMatcher.Matching result, + ContextStore prefixes) { + PekkoHttpRouteState state = PekkoHttpRouteState.current(); + if (state == null || state.remaining == null) { + return; + } + if (!(result instanceof PathMatcher.Matched)) { + state.unmatched(before); + return; + } + PathMatcher.Matched matched = (PathMatcher.Matched) result; + Uri.Path after = matched.pathRest(); + if (before.equals(after)) { + return; + } + // Most composed matchers finish at the remainder already recorded by their leaves. + if (after.equals(state.remaining)) { + return; + } + String literal = prefixes.get(matcher); + if (literal == null) { + if (matcher.getClass() == PathMatchers.Slash$.class) { + literal = "/"; + } else { + literal = before.startsWithSlash() ? "/{param}" : "{param}"; + } + } + state.matched(before, after, literal); + } +} diff --git a/dd-java-agent/instrumentation/pekko/pekko-http-1.0/src/main/java/datadog/trace/instrumentation/pekkohttp/PekkoHttpRouteModule.java b/dd-java-agent/instrumentation/pekko/pekko-http-1.0/src/main/java/datadog/trace/instrumentation/pekkohttp/PekkoHttpRouteModule.java new file mode 100644 index 00000000000..c9772715c9e --- /dev/null +++ b/dd-java-agent/instrumentation/pekko/pekko-http-1.0/src/main/java/datadog/trace/instrumentation/pekkohttp/PekkoHttpRouteModule.java @@ -0,0 +1,39 @@ +package datadog.trace.instrumentation.pekkohttp; + +import static datadog.trace.api.ConfigDefaults.DEFAULT_TRACE_OTEL_SEMANTICS_ENABLED; +import static datadog.trace.api.config.GeneralConfig.TRACE_OTEL_SEMANTICS_ENABLED; +import static java.util.Arrays.asList; +import static java.util.Collections.singletonMap; + +import com.google.auto.service.AutoService; +import datadog.trace.agent.tooling.Instrumenter; +import datadog.trace.agent.tooling.InstrumenterModule; +import datadog.trace.bootstrap.config.provider.ConfigProvider; +import java.util.List; +import java.util.Map; + +/** Captures matched routes and selects their completed directive branches. */ +@AutoService(InstrumenterModule.class) +public final class PekkoHttpRouteModule extends InstrumenterModule.Tracing { + public PekkoHttpRouteModule() { + super("pekko-http-route"); + } + + @Override + protected boolean defaultEnabled() { + return super.defaultEnabled() + && ConfigProvider.getInstance() + .getBoolean(TRACE_OTEL_SEMANTICS_ENABLED, DEFAULT_TRACE_OTEL_SEMANTICS_ENABLED); + } + + @Override + public Map contextStore() { + return singletonMap("org.apache.pekko.http.scaladsl.server.PathMatcher", "java.lang.String"); + } + + @Override + public List typeInstrumentations() { + return asList( + new PekkoHttpPathMatcherInstrumentation(), new PekkoHttpDirectiveInstrumentation()); + } +} diff --git a/dd-java-agent/instrumentation/pekko/pekko-http-1.0/src/main/java/datadog/trace/instrumentation/pekkohttp/PekkoHttpRouteState.java b/dd-java-agent/instrumentation/pekko/pekko-http-1.0/src/main/java/datadog/trace/instrumentation/pekkohttp/PekkoHttpRouteState.java new file mode 100644 index 00000000000..a8fbeaee3ad --- /dev/null +++ b/dd-java-agent/instrumentation/pekko/pekko-http-1.0/src/main/java/datadog/trace/instrumentation/pekkohttp/PekkoHttpRouteState.java @@ -0,0 +1,142 @@ +package datadog.trace.instrumentation.pekkohttp; + +import static datadog.context.ContextKey.named; +import static datadog.trace.bootstrap.instrumentation.decorator.http.HttpResourceDecorator.HTTP_RESOURCE_DECORATOR; +import static java.util.Collections.singletonList; + +import datadog.context.Context; +import datadog.context.ContextKey; +import datadog.trace.api.Config; +import datadog.trace.api.InstrumenterConfig; +import datadog.trace.api.KnownTags; +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import datadog.trace.bootstrap.instrumentation.api.Tags; +import org.apache.pekko.http.scaladsl.model.Uri; + +/** + * Route contributions owned by one directive invocation, with immutable history shared by child + * directives. + */ +public final class PekkoHttpRouteState { + private static final boolean OTEL_SEMANTICS = Config.get().isTraceOtelSemanticsEnabled(); + private static final ContextKey KEY = named("pekko-http-route"); + private static final boolean ROUTE_CAPTURE_ENABLED = + InstrumenterConfig.get() + .isIntegrationEnabled( + singletonList("pekko-http-route"), + InstrumenterConfig.get().isIntegrationsEnabled() && OTEL_SEMANTICS); + + Uri.Path remaining; + Part part; + + /** Set once this directive's result is known; later work under its context updates the parent. */ + private PekkoHttpRouteState retiredTo; + + PekkoHttpRouteState(Uri.Path remaining) { + this.remaining = remaining; + } + + public static Context init(Context context, Uri.Path requestPath) { + return ROUTE_CAPTURE_ENABLED + ? context.with(KEY, new PekkoHttpRouteState(requestPath)) + : context; + } + + static PekkoHttpRouteState current() { + PekkoHttpRouteState state = Context.current().get(KEY); + // A fallback can run under the completion context of a directive that already finished. + while (state != null && state.retiredTo != null) { + state = state.retiredTo; + } + return state; + } + + Context storeIn(Context context) { + return context.with(KEY, this); + } + + PekkoHttpRouteState fork() { + PekkoHttpRouteState child = new PekkoHttpRouteState(remaining); + child.part = part; + return child; + } + + void select(PekkoHttpRouteState child) { + part = child.part; + remaining = child.remaining; + } + + void retire(PekkoHttpRouteState child) { + child.retiredTo = this; + } + + void matched(Uri.Path before, Uri.Path after, String literal) { + if (!before.equals(remaining)) { + // Lookahead matchers can inspect input that a later directive consumes again. + unmatched(before); + if (!before.equals(remaining)) { + return; + } + } + part = new Part(before, literal, part); + remaining = after; + } + + void unmatched(Uri.Path before) { + // A composed matcher can fail after some of its leaves have consumed input. + for (Part cursor = part; cursor != null; cursor = cursor.previous) { + if (cursor.before.equals(before)) { + part = cursor.previous; + remaining = before; + return; + } + } + } + + String template() { + if (part == null) { + return null; + } + int length = 0; + for (Part cursor = part; cursor != null; cursor = cursor.previous) { + length += cursor.literal.length(); + } + char[] chars = new char[length]; + for (Part cursor = part; cursor != null; cursor = cursor.previous) { + String literal = cursor.literal; + length -= literal.length(); + literal.getChars(0, literal.length(), chars, length); + } + return new String(chars); + } + + /** Publishes only the selected route, before normal HTTP response naming. */ + public static void onComplete(Context context, AgentSpan span) { + PekkoHttpRouteState state = context.get(KEY); + if (state == null) { + return; + } + String template = state.template(); + state.part = null; + state.remaining = null; + if (template == null) { + return; + } + Object method = + span.getTag(OTEL_SEMANTICS ? KnownTags.HTTP_METHOD_OTEL_NAME : Tags.HTTP_METHOD); + HTTP_RESOURCE_DECORATOR.withRoute( + span, method == null ? null : method.toString(), template, true); + } + + static final class Part { + final Uri.Path before; + final String literal; + final Part previous; + + Part(Uri.Path before, String literal, Part previous) { + this.before = before; + this.literal = literal; + this.previous = previous; + } + } +} diff --git a/dd-java-agent/instrumentation/pekko/pekko-http-1.0/src/main/java/datadog/trace/instrumentation/pekkohttp/PekkoHttpRouteWrapper.java b/dd-java-agent/instrumentation/pekko/pekko-http-1.0/src/main/java/datadog/trace/instrumentation/pekkohttp/PekkoHttpRouteWrapper.java new file mode 100644 index 00000000000..f87b0bc32f2 --- /dev/null +++ b/dd-java-agent/instrumentation/pekko/pekko-http-1.0/src/main/java/datadog/trace/instrumentation/pekkohttp/PekkoHttpRouteWrapper.java @@ -0,0 +1,75 @@ +package datadog.trace.instrumentation.pekkohttp; + +import datadog.context.Context; +import datadog.context.ContextScope; +import org.apache.pekko.http.scaladsl.server.RequestContext; +import org.apache.pekko.http.scaladsl.server.RouteResult; +import scala.Function1; +import scala.Option; +import scala.concurrent.Future; +import scala.runtime.AbstractFunction1; +import scala.runtime.AbstractPartialFunction; +import scala.runtime.BoxedUnit; +import scala.util.Try; + +/** Isolates a directive invocation until its route result is known. */ +public final class PekkoHttpRouteWrapper + extends AbstractFunction1> { + private final Function1> delegate; + + private PekkoHttpRouteWrapper(Function1> delegate) { + this.delegate = delegate; + } + + public static Function1> wrap( + Function1> route) { + return route instanceof PekkoHttpRouteWrapper ? route : new PekkoHttpRouteWrapper(route); + } + + @Override + public Future apply(RequestContext request) { + PekkoHttpRouteState parent = PekkoHttpRouteState.current(); + if (parent == null) { + return delegate.apply(request); + } + PekkoHttpRouteState child = parent.fork(); + try (ContextScope ignored = child.storeIn(Context.current()).attach()) { + Future future; + try { + future = delegate.apply(request); + } catch (Throwable failure) { + parent.select(child); + parent.retire(child); + throw failure; + } + Option> value = future.value(); + if (value.isDefined()) { + select(parent, child, value.get()); + return future; + } + return future.andThen( + new AbstractPartialFunction, BoxedUnit>() { + @Override + public boolean isDefinedAt(Try result) { + return true; + } + + @Override + public BoxedUnit apply(Try result) { + select(parent, child, result); + return BoxedUnit.UNIT; + } + }, + request.executionContext()); + } + } + + private static void select( + PekkoHttpRouteState parent, PekkoHttpRouteState child, Try result) { + // Exceptions may be converted to a response by an enclosing exception handler. + if (result.isFailure() || result.get() instanceof RouteResult.Complete) { + parent.select(child); + } + parent.retire(child); + } +} diff --git a/dd-java-agent/instrumentation/play/play-2.3/src/main/java/datadog/trace/instrumentation/play23/PlayAdvice.java b/dd-java-agent/instrumentation/play/play-2.3/src/main/java/datadog/trace/instrumentation/play23/PlayAdvice.java index f114e48dcbf..334aefa5ead 100644 --- a/dd-java-agent/instrumentation/play/play-2.3/src/main/java/datadog/trace/instrumentation/play23/PlayAdvice.java +++ b/dd-java-agent/instrumentation/play/play-2.3/src/main/java/datadog/trace/instrumentation/play23/PlayAdvice.java @@ -31,8 +31,7 @@ public static ContextScope onEnter(@Advice.Argument(0) final Request req) { span = spanFromContext(context); scope = context.attach(); } else { - // An upstream framework (e.g. akka-http, netty) has already started the span. - // Do not extract the context. + // Keep the existing trace context instead of extracting the request headers again. span = startSpan(PLAY_ACTION.toString(), PLAY_REQUEST); span.setMeasured(true); scope = span.attachWithContext(); @@ -51,7 +50,6 @@ public static void stopTraceOnResponse( @Advice.Return(readOnly = false) final Future responseFuture) { final AgentSpan playControllerSpan = spanFromContext(playControllerScope.context()); - // Call onRequest on return after tags are populated. DECORATE.onRequest(playControllerSpan, req, req, rootContext()); if (throwable == null) { @@ -69,10 +67,9 @@ public static void stopTraceOnResponse( playControllerScope.close(); // span finished in RequestCompleteCallback - final AgentSpan rootSpan = activeSpan(); - // set the resource name on the upstream akka/netty span if there is one - if (rootSpan != null) { - DECORATE.onRequest(rootSpan, req, req, rootContext()); + final AgentSpan parentSpan = activeSpan(); + if (parentSpan != null) { + DECORATE.onRequest(parentSpan, req, req, rootContext()); } } } diff --git a/dd-java-agent/instrumentation/play/play-2.3/src/main/java/datadog/trace/instrumentation/play23/PlayHttpServerDecorator.java b/dd-java-agent/instrumentation/play/play-2.3/src/main/java/datadog/trace/instrumentation/play23/PlayHttpServerDecorator.java index b1589452e46..13fcf40bf6a 100644 --- a/dd-java-agent/instrumentation/play/play-2.3/src/main/java/datadog/trace/instrumentation/play23/PlayHttpServerDecorator.java +++ b/dd-java-agent/instrumentation/play/play-2.3/src/main/java/datadog/trace/instrumentation/play23/PlayHttpServerDecorator.java @@ -105,14 +105,20 @@ protected void doOnRequest( if (!pathOption.isEmpty()) { final String path = (String) pathOption.get(); HTTP_RESOURCE_DECORATOR.withRoute(span, request.method(), path); + if (OTEL_SEMANTICS) { + final AgentSpan serverSpan = getHttpServerSpan(span); + if (serverSpan != null && serverSpan != span) { + HTTP_RESOURCE_DECORATOR.withRoute(serverSpan, request.method(), path); + } + } dispatchRoute(span, path); } } } /** - * Play does not set the http.route in the local root span so we need to store it in the context - * for API security + * Store the route in the context for API security, including when legacy semantics leave it only + * on the Play span. */ private void dispatchRoute(final AgentSpan span, final String route) { try { diff --git a/dd-java-agent/instrumentation/play/play-2.4/src/main/java/datadog/trace/instrumentation/play24/PlayAdvice.java b/dd-java-agent/instrumentation/play/play-2.4/src/main/java/datadog/trace/instrumentation/play24/PlayAdvice.java index deb84b7b0bf..b97b094f212 100644 --- a/dd-java-agent/instrumentation/play/play-2.4/src/main/java/datadog/trace/instrumentation/play24/PlayAdvice.java +++ b/dd-java-agent/instrumentation/play/play-2.4/src/main/java/datadog/trace/instrumentation/play24/PlayAdvice.java @@ -37,8 +37,7 @@ public static ContextScope onEnter(@Advice.Argument(value = 0, readOnly = false) span = spanFromContext(context); scope = context.attach(); } else { - // An upstream framework (e.g. akka-http, netty) has already started the span. - // Do not extract the context. + // Keep the existing trace context instead of extracting the request headers again. span = startSpan(PLAY_ACTION.toString(), PLAY_REQUEST); span.setMeasured(true); scope = span.attachWithContext(); @@ -48,8 +47,6 @@ public static ContextScope onEnter(@Advice.Argument(value = 0, readOnly = false) req = RequestHelper.withTag(req, "_dd_HasPlayRequestSpan", "true"); - // Moved from OnMethodExit - // Call onRequest on return after tags are populated. DECORATE.onRequest(span, req, req, rootContext()); return scope; @@ -84,10 +81,9 @@ public static void stopTraceOnResponse( playControllerScope.close(); // span finished in RequestCompleteCallback - final AgentSpan rootSpan = activeSpan(); - // set the resource name on the upstream akka/netty span if there is one - if (rootSpan != null) { - DECORATE.onRequest(rootSpan, req, req, rootContext()); + final AgentSpan parentSpan = activeSpan(); + if (parentSpan != null) { + DECORATE.onRequest(parentSpan, req, req, rootContext()); } } diff --git a/dd-java-agent/instrumentation/play/play-2.4/src/main/java/datadog/trace/instrumentation/play24/PlayHttpServerDecorator.java b/dd-java-agent/instrumentation/play/play-2.4/src/main/java/datadog/trace/instrumentation/play24/PlayHttpServerDecorator.java index e5e80e81d05..100c56b5527 100644 --- a/dd-java-agent/instrumentation/play/play-2.4/src/main/java/datadog/trace/instrumentation/play24/PlayHttpServerDecorator.java +++ b/dd-java-agent/instrumentation/play/play-2.4/src/main/java/datadog/trace/instrumentation/play24/PlayHttpServerDecorator.java @@ -105,14 +105,21 @@ protected void doOnRequest( if (!pathOption.isEmpty()) { final String path = (String) pathOption.get(); HTTP_RESOURCE_DECORATOR.withRoute(span, request.method(), path); + if (OTEL_SEMANTICS) { + final AgentSpan serverSpan = getHttpServerSpan(span); + if (serverSpan != null && serverSpan != span) { + // Sampling may run inside the action, so set the server route now. + HTTP_RESOURCE_DECORATOR.withRoute(serverSpan, request.method(), path); + } + } dispatchRoute(span, path); } } } /** - * Play does not set the http.route in the local root span so we need to store it in the context - * for API security + * Store the route in the context for API security, including when legacy semantics leave it only + * on the Play span. */ private void dispatchRoute(final AgentSpan span, final String route) { try { diff --git a/dd-java-agent/instrumentation/play/play-2.6/src/main/java/datadog/trace/instrumentation/play26/PlayAdvice.java b/dd-java-agent/instrumentation/play/play-2.6/src/main/java/datadog/trace/instrumentation/play26/PlayAdvice.java index f1c66f17bbb..5120acadea3 100644 --- a/dd-java-agent/instrumentation/play/play-2.6/src/main/java/datadog/trace/instrumentation/play26/PlayAdvice.java +++ b/dd-java-agent/instrumentation/play/play-2.6/src/main/java/datadog/trace/instrumentation/play26/PlayAdvice.java @@ -38,8 +38,7 @@ public static ContextScope onEnter(@Advice.Argument(value = 0, readOnly = false) span = spanFromContext(context); scope = context.attach(); } else { - // An upstream framework (e.g. akka-http, netty) has already started the span. - // Do not extract the context. + // Keep the existing trace context instead of extracting the request headers again. parentContext = rootContext(); span = startSpan(PLAY_ACTION.toString(), PLAY_REQUEST); scope = span.attachWithContext(); @@ -50,8 +49,6 @@ public static ContextScope onEnter(@Advice.Argument(value = 0, readOnly = false) req = req.addAttr(HasPlayRequestSpan.KEY, HasPlayRequestSpan.INSTANCE); - // Moved from OnMethodExit - // Call onRequest on return after tags are populated. DECORATE.onRequest(span, req, req, parentContext); return scope; @@ -83,10 +80,9 @@ public static void stopTraceOnResponse( playControllerScope.close(); // span finished in RequestCompleteCallback - final AgentSpan rootSpan = activeSpan(); - // set the resource name on the upstream akka/netty span if there is one - if (rootSpan != null && playControllerSpan.getResourceName() != null) { - rootSpan.setResourceName( + final AgentSpan parentSpan = activeSpan(); + if (parentSpan != null && playControllerSpan.getResourceName() != null) { + parentSpan.setResourceName( playControllerSpan.getResourceName(), ResourceNamePriorities.HTTP_FRAMEWORK_ROUTE); } } diff --git a/dd-java-agent/instrumentation/play/play-2.6/src/main/java/datadog/trace/instrumentation/play26/PlayHttpServerDecorator.java b/dd-java-agent/instrumentation/play/play-2.6/src/main/java/datadog/trace/instrumentation/play26/PlayHttpServerDecorator.java index 1018b576f3d..791ef666cda 100644 --- a/dd-java-agent/instrumentation/play/play-2.6/src/main/java/datadog/trace/instrumentation/play26/PlayHttpServerDecorator.java +++ b/dd-java-agent/instrumentation/play/play-2.6/src/main/java/datadog/trace/instrumentation/play26/PlayHttpServerDecorator.java @@ -153,14 +153,21 @@ protected void doOnRequest( PATH_CACHE.computeIfAbsent( defOption.get().path(), p -> addMissingSlash(p, request.path())); HTTP_RESOURCE_DECORATOR.withRoute(span, request.method(), path, true); + if (OTEL_SEMANTICS) { + final AgentSpan serverSpan = getHttpServerSpan(span); + if (serverSpan != null && serverSpan != span) { + // Sampling may run inside the action, so set the server route now. + HTTP_RESOURCE_DECORATOR.withRoute(serverSpan, request.method(), path, true); + } + } dispatchRoute(span, path); } } } /** - * Play does not set the http.route in the local root span so we need to store it in the context - * for API security + * Store the route in the context for API security, including when legacy semantics leave it only + * on the Play span. */ private void dispatchRoute(final AgentSpan span, final CharSequence route) { try { diff --git a/dd-java-agent/instrumentation/play/play-appsec-2.5/src/test/groovy/datadog/trace/instrumentation/play25/server/PlayScalaRoutesServerTest.groovy b/dd-java-agent/instrumentation/play/play-appsec-2.5/src/test/groovy/datadog/trace/instrumentation/play25/server/PlayScalaRoutesServerTest.groovy index abbac40db83..bb24b4fb5d7 100644 --- a/dd-java-agent/instrumentation/play/play-appsec-2.5/src/test/groovy/datadog/trace/instrumentation/play25/server/PlayScalaRoutesServerTest.groovy +++ b/dd-java-agent/instrumentation/play/play-appsec-2.5/src/test/groovy/datadog/trace/instrumentation/play25/server/PlayScalaRoutesServerTest.groovy @@ -1,15 +1,21 @@ package datadog.trace.instrumentation.play25.server +import datadog.context.Context +import datadog.trace.agent.test.InstrumentationSpecification import datadog.trace.agent.test.asserts.TraceAssert import datadog.trace.agent.test.base.HttpServer import datadog.trace.agent.test.base.HttpServerTest import datadog.trace.api.DDSpanTypes import datadog.trace.api.DDTags import datadog.trace.api.config.TraceInstrumentationConfig +import datadog.trace.api.gateway.InferredProxySpan import datadog.trace.bootstrap.instrumentation.api.Tags +import datadog.trace.instrumentation.play24.PlayAdvice +import datadog.trace.instrumentation.play24.PlayHttpServerDecorator import datadog.trace.instrumentation.play25.PlayController import datadog.trace.test.util.Flaky import groovy.transform.CompileStatic +import play.api.mvc.Request import scala.concurrent.ExecutionContext$ import spock.lang.Shared @@ -22,6 +28,7 @@ import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.FORWAR import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.NOT_FOUND import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.PATH_PARAM import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.SUCCESS +import static datadog.trace.bootstrap.instrumentation.api.AgentSpan.fromContext class PlayScalaRoutesServerTest extends PlayServerTest { @Shared @@ -116,3 +123,68 @@ class PlayScalaRoutesServerTest extends PlayServerTest { } } } + +class PlayRouteTracingFilterOtelSemanticsForkedTest extends InstrumentationSpecification { + @Override + protected void configurePreAgent() { + super.configurePreAgent() + injectSysConfig('trace.inferred.proxy.services.enabled', 'true') + injectSysConfig('trace.otel.semantics.enabled', 'true') + } + + def "tracing filters do not receive the HTTP server route before sampling"() { + setup: + def routeTags = scala.collection.immutable.Map$.MODULE$.empty().updated('ROUTE_PATTERN', '/items/{param}') + def request = Stub(Request) { + tags() >> routeTags + method() >> 'GET' + path() >> '/items/value' + host() >> 'localhost' + remoteAddress() >> '127.0.0.1' + rawQueryString() >> '' + } + request.withTag(_, _) >> request + def inferred = InferredProxySpan.fromHeaders([ + 'x-dd-proxy': 'aws-apigateway', + 'x-dd-proxy-request-time-ms': Long.toString(System.currentTimeMillis()), + 'x-dd-proxy-httpmethod': 'GET', + 'x-dd-proxy-path': '/public/items/value', + 'x-dd-proxy-resource-path': '/public/{proxy+}' + ]) + def parent = inferredProxy ? Context.root().with(inferred) : Context.root() + def serverContext = PlayHttpServerDecorator.DECORATE.startSpan(null, parent) + def server = fromContext(serverContext) + server.setResourceName('GET') + def serverScope = serverContext.attach() + def filter = TEST_TRACER.startSpan('test', 'filter1') + filter.setResourceName('filter1') + def filterScope = TEST_TRACER.activateManualSpan(filter) + def playScope + def play + + when: + playScope = PlayAdvice.onEnter(request) + play = fromContext(playScope.context()) + + then: + play.getTag('http.route') == '/items/{param}' + server.getTag('http.route') == '/items/{param}' + server.resourceName.toString() == 'GET /items/{param}' + filter.getTag('http.route') == null + filter.resourceName.toString() == 'filter1' + !inferredProxy || server.localRootSpan.getTag('http.route') == '/public/{proxy+}' + !inferredProxy || server.localRootSpan.resourceName.toString() == 'GET /public/{proxy+}' + + cleanup: + playScope?.close() + play?.finish() + filterScope?.close() + filter?.finish() + serverScope?.close() + server?.finish() + inferred?.finish() + + where: + inferredProxy << [false, true] + } +} diff --git a/dd-java-agent/instrumentation/play/play-appsec-2.7/src/test/groovy/datadog/trace/instrumentation/play27/server/test/PlayAsyncServerRoutesScalaWithErrorHandlerTest.groovy b/dd-java-agent/instrumentation/play/play-appsec-2.7/src/test/groovy/datadog/trace/instrumentation/play27/server/test/PlayAsyncServerRoutesScalaWithErrorHandlerTest.groovy index 721aed82005..59ddf821ee5 100644 --- a/dd-java-agent/instrumentation/play/play-appsec-2.7/src/test/groovy/datadog/trace/instrumentation/play27/server/test/PlayAsyncServerRoutesScalaWithErrorHandlerTest.groovy +++ b/dd-java-agent/instrumentation/play/play-appsec-2.7/src/test/groovy/datadog/trace/instrumentation/play27/server/test/PlayAsyncServerRoutesScalaWithErrorHandlerTest.groovy @@ -1,15 +1,25 @@ package datadog.trace.instrumentation.play27.server.test +import datadog.context.Context +import datadog.trace.agent.test.InstrumentationSpecification import datadog.trace.agent.test.asserts.TraceAssert import datadog.trace.agent.test.base.HttpServer import datadog.trace.agent.test.base.HttpServerTest +import datadog.trace.agent.test.naming.TestingOtelHttpSemantics +import datadog.trace.api.Config import datadog.trace.api.DDSpanTypes import datadog.trace.api.DDTags +import datadog.trace.api.KnownTags import datadog.trace.api.config.TraceInstrumentationConfig +import datadog.trace.api.gateway.InferredProxySpan import datadog.trace.bootstrap.instrumentation.api.Tags +import datadog.trace.instrumentation.play26.PlayAdvice import datadog.trace.instrumentation.play26.PlayHttpServerDecorator import datadog.trace.instrumentation.play26.server.TestHttpErrorHandler import play.api.BuiltInComponents +import play.api.libs.typedmap.TypedMap$ +import play.api.mvc.Request as ScalaRequest +import play.api.routing.HandlerDef import play.libs.concurrent.ClassLoaderExecution import play.routing.Router import spock.lang.Shared @@ -24,6 +34,7 @@ import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.FORWAR import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.NOT_FOUND import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.PATH_PARAM import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.SUCCESS +import static datadog.trace.bootstrap.instrumentation.api.AgentSpan.fromContext class PlayAsyncServerRoutesScalaWithErrorHandlerTest extends AbstractPlayServer27WithErrorHandlerTest { @Shared @@ -73,10 +84,24 @@ class PlayAsyncServerRoutesScalaWithErrorHandlerTest extends AbstractPlayServer2 } } + @Override + Serializable expectedServerSpanRoute(HttpServerTest.ServerEndpoint endpoint) { + Config.get().isTraceOtelSemanticsEnabled() ? routeFor(endpoint) : null + } + + @Override + Map expectedExtraServerTags(HttpServerTest.ServerEndpoint endpoint) { + Config.get().isTraceOtelSemanticsEnabled() ? [:] : [(Tags.HTTP_ROUTE): null] + } + // we set Tags.HTTP_ROUTE with a routes file; override method for this @Override void handlerSpan(TraceAssert trace, HttpServerTest.ServerEndpoint endpoint = SUCCESS) { def expectedQueryTag = expectedQueryTag(endpoint) + def expectedRoute = routeFor(endpoint) + def otelSemantics = Config.get().isTraceOtelSemanticsEnabled() + def requestUrl = endpoint.resolve(address) + def expectedPath = Config.get().isHttpServerRawResource() && supportsRaw() ? requestUrl.rawPath : requestUrl.path trace.span { serviceName expectedServiceName() operationName "play.request" @@ -89,18 +114,102 @@ class PlayAsyncServerRoutesScalaWithErrorHandlerTest extends AbstractPlayServer2 "$Tags.PEER_HOST_IPV4" '127.0.0.1' "$Tags.HTTP_CLIENT_IP" (endpoint == FORWARDED ? endpoint.body : '127.0.0.1') "$Tags.NETWORK_CLIENT_IP" '127.0.0.1' - "$Tags.HTTP_URL" String - "$Tags.HTTP_HOSTNAME" address.host + if (otelSemantics) { + "$Tags.HTTP_URL" null + "$Tags.HTTP_HOSTNAME" address.host + "$KnownTags.SERVER_PORT_NAME" address.port + "$KnownTags.URL_PATH_NAME" expectedPath + "$KnownTags.URL_SCHEME_NAME" address.scheme + } else { + "$Tags.HTTP_URL" String + "$Tags.HTTP_HOSTNAME" address.host + } "$Tags.HTTP_METHOD" String - "$Tags.HTTP_ROUTE" this.routeFor(endpoint) + "$Tags.HTTP_ROUTE" expectedRoute if (endpoint == EXCEPTION || endpoint == CUSTOM_EXCEPTION) { errorTags(endpoint == CUSTOM_EXCEPTION ? TestHttpErrorHandler.CustomRuntimeException : RuntimeException, endpoint.body) } if (endpoint.query) { - "$DDTags.HTTP_QUERY" expectedQueryTag + tag(otelSemantics ? KnownTags.URL_QUERY_NAME : DDTags.HTTP_QUERY, expectedQueryTag) } defaultTags() } } } } + +class PlayAsyncServerRoutesOtelSemanticsForkedTest extends PlayAsyncServerRoutesScalaWithErrorHandlerTest implements TestingOtelHttpSemantics {} + +class PlayRouteTracingFilterOtelSemanticsForkedTest extends InstrumentationSpecification { + @Override + protected void configurePreAgent() { + super.configurePreAgent() + injectSysConfig('trace.inferred.proxy.services.enabled', 'true') + injectSysConfig('trace.otel.semantics.enabled', 'true') + } + + def "application routes preserve gateway routes and skip tracing filters before sampling"() { + setup: + def handler = Stub(HandlerDef) { + path() >> '/items/{param}' + } + def requestAttributes = TypedMap$.MODULE$.empty().updated(Router.Attrs.HANDLER_DEF.asScala(), handler) + def request = Stub(ScalaRequest) { + attrs() >> requestAttributes + method() >> 'GET' + path() >> '/items/value' + host() >> 'localhost' + remoteAddress() >> '127.0.0.1' + rawQueryString() >> '' + } + request.addAttr(_, _) >> request + def inferred = InferredProxySpan.fromHeaders([ + 'x-dd-proxy': 'aws-apigateway', + 'x-dd-proxy-request-time-ms': Long.toString(System.currentTimeMillis()), + 'x-dd-proxy-httpmethod': 'GET', + 'x-dd-proxy-path': '/public/items/value', + 'x-dd-proxy-resource-path': '/public/{proxy+}' + ]) + def parent = inferredProxy ? Context.root().with(inferred) : Context.root() + def serverContext = PlayHttpServerDecorator.DECORATE.startSpan(null, parent) + def server = fromContext(serverContext) + server.setResourceName('GET') + def serverScope = serverContext.attach() + def filter = tracingFilter ? TEST_TRACER.startSpan('test', 'filter1') : null + filter?.setResourceName('filter1') + def filterScope = filter ? TEST_TRACER.activateManualSpan(filter) : null + def playScope + def play + + when: + playScope = PlayAdvice.onEnter(request) + play = fromContext(playScope.context()) + + then: + play.getTag('http.route') == '/items/{param}' + server.getTag('http.route') == '/items/{param}' + server.resourceName.toString() == 'GET /items/{param}' + play.parentId == (filter ?: server).spanId + !filter || filter.getTag('http.route') == null + !filter || filter.resourceName.toString() == 'filter1' + !inferredProxy || server.parentId == server.localRootSpan.spanId + !inferredProxy || server.localRootSpan.getTag('http.route') == '/public/{proxy+}' + !inferredProxy || server.localRootSpan.resourceName.toString() == 'GET /public/{proxy+}' + + cleanup: + playScope?.close() + play?.finish() + filterScope?.close() + filter?.finish() + serverScope?.close() + server?.finish() + inferred?.finish() + + where: + inferredProxy | tracingFilter + false | false + false | true + true | false + true | true + } +} diff --git a/dd-java-agent/instrumentation/spring/spring-webflux/spring-webflux-5.0/src/test/groovy/dd/trace/instrumentation/springwebflux/client/SpringWebfluxHttpClientBase.groovy b/dd-java-agent/instrumentation/spring/spring-webflux/spring-webflux-5.0/src/test/groovy/dd/trace/instrumentation/springwebflux/client/SpringWebfluxHttpClientBase.groovy index 066f37474e6..672f19c70ae 100644 --- a/dd-java-agent/instrumentation/spring/spring-webflux/spring-webflux-5.0/src/test/groovy/dd/trace/instrumentation/springwebflux/client/SpringWebfluxHttpClientBase.groovy +++ b/dd-java-agent/instrumentation/spring/spring-webflux/spring-webflux-5.0/src/test/groovy/dd/trace/instrumentation/springwebflux/client/SpringWebfluxHttpClientBase.groovy @@ -3,8 +3,11 @@ package dd.trace.instrumentation.springwebflux.client import datadog.trace.agent.test.asserts.TraceAssert import datadog.trace.agent.test.base.HttpClientTest import datadog.trace.agent.test.naming.TestingGenericHttpNamingConventions +import datadog.trace.api.Config import datadog.trace.api.DDSpanTypes import datadog.trace.api.DDTags +import datadog.trace.api.KnownTags +import datadog.trace.bootstrap.instrumentation.api.OtelHttpSemantics import datadog.trace.bootstrap.instrumentation.api.Tags import datadog.trace.bootstrap.instrumentation.api.URIUtils import datadog.trace.instrumentation.netty41.client.NettyHttpClientDecorator @@ -90,6 +93,7 @@ abstract class SpringWebfluxHttpClientBase extends HttpClientTest implements Tes boolean ignorePeer = false, Map extraTags = null) { + def otelSemantics = Config.get().isTraceOtelSemanticsEnabled() def leafParentId = trace.spanAssertCount.get() super.clientSpan(trace, parentSpan, method, renameService, tagQueryString, uri, status, error, exception, ignorePeer, extraTags) if (!exception) { @@ -104,9 +108,9 @@ abstract class SpringWebfluxHttpClientBase extends HttpClientTest implements Tes serviceName("localhost") } operationName NettyHttpClientDecorator.DECORATE.operationName() - resourceName "$method $uri.path" + resourceName otelSemantics ? OtelHttpSemantics.spanNameMethod(method) : "$method $uri.path" spanType DDSpanTypes.HTTP_CLIENT - errored error + errored otelSemantics && status != null && status >= 400 ? true : error measured true tags { "$Tags.COMPONENT" NettyHttpClientDecorator.DECORATE.component() @@ -119,17 +123,26 @@ abstract class SpringWebfluxHttpClientBase extends HttpClientTest implements Tes if (status) { "$Tags.HTTP_STATUS" status } - if (tagQueryString) { + if (otelSemantics) { + "$KnownTags.HTTP_QUERY_STRING_NAME" null + "$DDTags.HTTP_FRAGMENT" null + } else if (tagQueryString) { "$DDTags.HTTP_QUERY" expectedQuery "$DDTags.HTTP_FRAGMENT" { it == null || it == uri.fragment } // Optional } if (exception) { errorTags(exception.class, exception.message) + } else if (otelSemantics && status != null && status >= 400) { + "$DDTags.ERROR_TYPE" status.toString() } if ({ isDataStreamsEnabled() }) { "$DDTags.PATHWAY_HASH" { String } } - defaultTags() + if (otelSemantics) { + defaultTagsNoPeerService() + } else { + defaultTags() + } if (extraTags) { it.addTags(extraTags) } diff --git a/dd-java-agent/instrumentation/spring/spring-webflux/spring-webflux-5.0/src/test/groovy/dd/trace/instrumentation/springwebflux/client/SpringWebfluxHttpClientBasicTest.groovy b/dd-java-agent/instrumentation/spring/spring-webflux/spring-webflux-5.0/src/test/groovy/dd/trace/instrumentation/springwebflux/client/SpringWebfluxHttpClientBasicTest.groovy index 69886df3eaa..a6cd953b133 100644 --- a/dd-java-agent/instrumentation/spring/spring-webflux/spring-webflux-5.0/src/test/groovy/dd/trace/instrumentation/springwebflux/client/SpringWebfluxHttpClientBasicTest.groovy +++ b/dd-java-agent/instrumentation/spring/spring-webflux/spring-webflux-5.0/src/test/groovy/dd/trace/instrumentation/springwebflux/client/SpringWebfluxHttpClientBasicTest.groovy @@ -1,5 +1,6 @@ package dd.trace.instrumentation.springwebflux.client +import datadog.trace.agent.test.naming.TestingOtelHttpSemantics import org.springframework.web.reactive.function.client.WebClient import spock.lang.Timeout @@ -15,3 +16,7 @@ class SpringWebfluxHttpClientBasicTest extends SpringWebfluxHttpClientBase { void check() { } } + +@Timeout(5) +class SpringWebfluxHttpClientOtelSemanticsForkedTest extends SpringWebfluxHttpClientBasicTest implements TestingOtelHttpSemantics { +} diff --git a/dd-trace-api/src/main/java/datadog/trace/api/ConfigDefaults.java b/dd-trace-api/src/main/java/datadog/trace/api/ConfigDefaults.java index 0035fcc32e3..d933d172eed 100644 --- a/dd-trace-api/src/main/java/datadog/trace/api/ConfigDefaults.java +++ b/dd-trace-api/src/main/java/datadog/trace/api/ConfigDefaults.java @@ -127,7 +127,7 @@ public final class ConfigDefaults { public static final boolean DEFAULT_METRICS_OTEL_EXPERIMENTAL_ENABLED = true; - static final boolean DEFAULT_TRACE_OTEL_SEMANTICS_ENABLED = false; + public static final boolean DEFAULT_TRACE_OTEL_SEMANTICS_ENABLED = false; public static final int DEFAULT_OTLP_TRACES_TIMEOUT = 10_000; // ms static final String DEFAULT_OTLP_HTTP_LOGS_ENDPOINT = "v1/logs"; diff --git a/internal-api/src/main/java/datadog/trace/api/gateway/InferredProxySpan.java b/internal-api/src/main/java/datadog/trace/api/gateway/InferredProxySpan.java index 2c19b0772e3..f6ee480db3e 100644 --- a/internal-api/src/main/java/datadog/trace/api/gateway/InferredProxySpan.java +++ b/internal-api/src/main/java/datadog/trace/api/gateway/InferredProxySpan.java @@ -221,6 +221,11 @@ public void registerServiceEntrySpan(AgentSpan serviceEntrySpan) { this.registeredServiceEntrySpan = serviceEntrySpan; } + /** Returns the registered application server span, or {@code null} if none is registered. */ + public AgentSpan getServiceEntrySpan() { + return registeredServiceEntrySpan; + } + public void finish() { finish(null); } diff --git a/metadata/agent-jar-checks.properties b/metadata/agent-jar-checks.properties index a5f8380fd4c..39977ab5205 100644 --- a/metadata/agent-jar-checks.properties +++ b/metadata/agent-jar-checks.properties @@ -12,6 +12,7 @@ classes.minimum.guard = 17000 expected.integrations = IastInstrumentation,\ aerospike,\ akka-http,\ + akka-http-route,\ akka-http2,\ akka_actor_mailbox,\ akka_actor_receive,\ @@ -138,6 +139,7 @@ expected.integrations = IastInstrumentation,\ opentracing,\ org-json,\ pekko-http,\ + pekko-http-route,\ pekko-http2,\ pekko_actor_mailbox,\ pekko_actor_receive,\ @@ -145,9 +147,9 @@ expected.integrations = IastInstrumentation,\ play,\ play-ws,\ quartz,\ + r2dbc,\ ratpack,\ ratpack-request-body,\ - r2dbc,\ reactive-streams,\ reactor-core,\ reactor-netty,\ diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json index 4b71411c15d..4db17d95baf 100644 --- a/metadata/supported-configurations.json +++ b/metadata/supported-configurations.json @@ -4572,6 +4572,14 @@ "aliases": ["DD_TRACE_INTEGRATION_AKKA_HTTP_ENABLED", "DD_INTEGRATION_AKKA_HTTP_ENABLED"] } ], + "DD_TRACE_AKKA_HTTP_ROUTE_ENABLED": [ + { + "version": "A", + "type": "boolean", + "default": "false", + "aliases": ["DD_TRACE_INTEGRATION_AKKA_HTTP_ROUTE_ENABLED", "DD_INTEGRATION_AKKA_HTTP_ROUTE_ENABLED"] + } + ], "DD_TRACE_AKKA_HTTP_SERVER_ANALYTICS_ENABLED": [ { "version": "A", @@ -9100,6 +9108,14 @@ "aliases": ["DD_TRACE_INTEGRATION_PEKKO_HTTP_ENABLED", "DD_INTEGRATION_PEKKO_HTTP_ENABLED"] } ], + "DD_TRACE_PEKKO_HTTP_ROUTE_ENABLED": [ + { + "version": "A", + "type": "boolean", + "default": "false", + "aliases": ["DD_TRACE_INTEGRATION_PEKKO_HTTP_ROUTE_ENABLED", "DD_INTEGRATION_PEKKO_HTTP_ROUTE_ENABLED"] + } + ], "DD_TRACE_PEKKO_HTTP_SERVER_ANALYTICS_ENABLED": [ { "version": "A",