diff --git a/build.sbt b/build.sbt index f520304a3..47770bb85 100644 --- a/build.sbt +++ b/build.sbt @@ -147,7 +147,8 @@ val instrumentationProjects = Seq[ProjectReference]( `kamon-aws-sdk`, `kamon-alpakka-kafka`, `kamon-http4s-1_0`, - `kamon-http4s-0_23` + `kamon-http4s-0_23`, + `kamon-apache-httpclient` ) lazy val instrumentation = (project in file("instrumentation")) @@ -820,6 +821,25 @@ lazy val `kamon-http4s-0_23` = (project in file("instrumentation/kamon-http4s-0. `kamon-testkit` % Test ) +lazy val `kamon-apache-httpclient` = (project in file("instrumentation/kamon-apache-httpclient")) + .disablePlugins(AssemblyPlugin) + .enablePlugins(JavaAgent) + .settings(instrumentationSettings) + .settings( + libraryDependencies ++= Seq( + kanelaAgent % "provided", + "org.apache.httpcomponents" % "httpclient" % "4.0" % "provided", + slf4jApi % "provided", + + scalatest % "test", + logbackClassic % "test", + "org.mock-server" % "mockserver-client-java" % "5.13.2" % "test", + "com.dimafeng" %% "testcontainers-scala" % "0.41.0" % "test", + "com.dimafeng" %% "testcontainers-scala-mockserver" % "0.41.0" % "test" + ) + ).dependsOn(`kamon-core`, `kamon-executors`, `kamon-testkit` % "test") + + /** * Reporters */ @@ -1087,7 +1107,8 @@ lazy val `kamon-bundle-dependencies-all` = (project in file("bundle/kamon-bundle `kamon-okhttp`, `kamon-caffeine`, `kamon-lagom`, - `kamon-aws-sdk` + `kamon-aws-sdk`, + `kamon-apache-httpclient` ) /** @@ -1151,7 +1172,8 @@ lazy val `kamon-bundle-dependencies-3` = (project in file("bundle/kamon-bundle-d `kamon-zio-2`, `kamon-pekko`, `kamon-pekko-http`, - `kamon-pekko-grpc` + `kamon-pekko-grpc`, + `kamon-apache-httpclient` ) lazy val `kamon-bundle` = (project in file("bundle/kamon-bundle")) diff --git a/instrumentation/kamon-apache-httpclient/src/main/java/kamon/instrumentation/apache/httpclient/RequestAdvisor.java b/instrumentation/kamon-apache-httpclient/src/main/java/kamon/instrumentation/apache/httpclient/RequestAdvisor.java new file mode 100644 index 000000000..a1d36ab00 --- /dev/null +++ b/instrumentation/kamon-apache-httpclient/src/main/java/kamon/instrumentation/apache/httpclient/RequestAdvisor.java @@ -0,0 +1,48 @@ +package kamon.instrumentation.apache.httpclient; + +import org.apache.http.HttpHost; +import org.apache.http.HttpRequest; +import org.apache.http.HttpResponse; + +import kamon.instrumentation.http.HttpClientInstrumentation.RequestHandler; +import kamon.instrumentation.http.HttpMessage.RequestBuilder; +import kamon.trace.Span; +import kamon.Kamon; +import kamon.context.Context; +import kamon.context.Storage.Scope; +import kamon.instrumentation.context.HasContext; + +import kanela.agent.libs.net.bytebuddy.asm.Advice; + +public class RequestAdvisor { + + @Advice.OnMethodEnter(suppress = Throwable.class) + public static void onEnter(@Advice.Argument(0) HttpHost host, + @Advice.Argument(value = 1, readOnly = false) HttpRequest request, + @Advice.Local("handler") RequestHandler handler, + @Advice.Local("scope") Scope scope) { + if (((HasContext) request).context().nonEmpty()) { + // Request has been instrumented already + return; + } + final Context parentContext = Kamon.currentContext(); + final RequestBuilder builder = ApacheHttpClientHelper.toRequestBuilder(host, request); + handler = ApacheHttpClientInstrumentation.httpClientInstrumentation().createHandler(builder, parentContext); + final Context ctx = parentContext.withEntry(Span.Key(), handler.span()); + scope = Kamon.storeContext(ctx); + request = handler.request(); + ((HasContext) request).setContext(ctx); + } + + @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class) + public static void onExit(@Advice.Return HttpResponse response, + @Advice.Thrown Throwable t, + @Advice.Local("handler") RequestHandler handler, + @Advice.Local("scope") Scope scope) { + if (scope == null) { + return; + } + ApacheHttpClientInstrumentation.processResponse(handler, response, t); + scope.close(); + } +} \ No newline at end of file diff --git a/instrumentation/kamon-apache-httpclient/src/main/java/kamon/instrumentation/apache/httpclient/RequestWithHandlerAdvisor.java b/instrumentation/kamon-apache-httpclient/src/main/java/kamon/instrumentation/apache/httpclient/RequestWithHandlerAdvisor.java new file mode 100644 index 000000000..554609288 --- /dev/null +++ b/instrumentation/kamon-apache-httpclient/src/main/java/kamon/instrumentation/apache/httpclient/RequestWithHandlerAdvisor.java @@ -0,0 +1,44 @@ +package kamon.instrumentation.apache.httpclient; + +import org.apache.http.HttpHost; +import org.apache.http.HttpRequest; +import org.apache.http.client.ResponseHandler; + +import kamon.Kamon; +import kamon.context.Context; +import kamon.context.Storage.Scope; +import kamon.instrumentation.context.HasContext; +import kamon.instrumentation.http.HttpClientInstrumentation.RequestHandler; +import kamon.instrumentation.http.HttpMessage.RequestBuilder; +import kamon.trace.Span; +import kanela.agent.libs.net.bytebuddy.asm.Advice; + +public class RequestWithHandlerAdvisor { + + @Advice.OnMethodEnter(suppress = Throwable.class) + public static void onEnter(@Advice.Argument(0) HttpHost host, + @Advice.Argument(value = 1, readOnly = false) HttpRequest request, + @Advice.Argument(value = 2, readOnly = false) ResponseHandler resHandler, + @Advice.Local("handler") RequestHandler reqHandler, + @Advice.Local("scope") Scope scope) { + if (((HasContext) request).context().nonEmpty()) { + // Request has been instrumented already + return; + } + final Context parentContext = Kamon.currentContext(); + final RequestBuilder builder = ApacheHttpClientHelper.toRequestBuilder(host, request); + reqHandler = ApacheHttpClientInstrumentation.httpClientInstrumentation().createHandler(builder, parentContext); + resHandler = new ResponseHandlerProxy<>(reqHandler, resHandler, parentContext); + final Context ctx = parentContext.withEntry(Span.Key(), reqHandler.span()); + scope = Kamon.storeContext(ctx); + request = reqHandler.request(); + ((HasContext) request).setContext(ctx); + } + + @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class) + public static void onExit(@Advice.Local("scope") Scope scope) { + if (scope != null) { + scope.close(); + } + } +} \ No newline at end of file diff --git a/instrumentation/kamon-apache-httpclient/src/main/java/kamon/instrumentation/apache/httpclient/ResponseHandlerProxy.java b/instrumentation/kamon-apache-httpclient/src/main/java/kamon/instrumentation/apache/httpclient/ResponseHandlerProxy.java new file mode 100644 index 000000000..cb7224742 --- /dev/null +++ b/instrumentation/kamon-apache-httpclient/src/main/java/kamon/instrumentation/apache/httpclient/ResponseHandlerProxy.java @@ -0,0 +1,35 @@ +package kamon.instrumentation.apache.httpclient; + +import java.io.IOException; + +import org.apache.http.HttpResponse; +import org.apache.http.client.ClientProtocolException; +import org.apache.http.client.ResponseHandler; + +import kamon.Kamon; +import kamon.context.Context; +import kamon.context.Storage.Scope; +import kamon.instrumentation.http.HttpClientInstrumentation.RequestHandler; + +public class ResponseHandlerProxy implements ResponseHandler { + + private final ResponseHandler delegate; + private final RequestHandler handler; + private Context parentContext; + + public ResponseHandlerProxy(RequestHandler handler, ResponseHandler delegate, Context parentContext) { + this.handler = handler; + this.delegate = delegate; + this.parentContext = parentContext; + } + + @Override + public T handleResponse(HttpResponse response) throws ClientProtocolException, IOException { + ApacheHttpClientInstrumentation.processResponse(handler, response, null); + // run original handler in parent context to avoid nesting of spans + try (Scope ignored = Kamon.storeContext(parentContext)) { + return delegate.handleResponse(response); + } + } + +} \ No newline at end of file diff --git a/instrumentation/kamon-apache-httpclient/src/main/java/kamon/instrumentation/apache/httpclient/UriRequestAdvisor.java b/instrumentation/kamon-apache-httpclient/src/main/java/kamon/instrumentation/apache/httpclient/UriRequestAdvisor.java new file mode 100644 index 000000000..c5dee402f --- /dev/null +++ b/instrumentation/kamon-apache-httpclient/src/main/java/kamon/instrumentation/apache/httpclient/UriRequestAdvisor.java @@ -0,0 +1,45 @@ +package kamon.instrumentation.apache.httpclient; + +import org.apache.http.HttpResponse; +import org.apache.http.client.methods.HttpUriRequest; + +import kamon.Kamon; +import kamon.context.Context; +import kamon.context.Storage.Scope; +import kamon.instrumentation.context.HasContext; +import kamon.instrumentation.http.HttpClientInstrumentation.RequestHandler; +import kamon.instrumentation.http.HttpMessage.RequestBuilder; +import kamon.trace.Span; +import kanela.agent.libs.net.bytebuddy.asm.Advice; + +public class UriRequestAdvisor { + + @Advice.OnMethodEnter(suppress = Throwable.class) + public static void onEnter(@Advice.Argument(value = 0, readOnly = false) HttpUriRequest request, + @Advice.Local("handler") RequestHandler handler, + @Advice.Local("scope") Scope scope) { + if (((HasContext) request).context().nonEmpty()) { + // Request has been instrumented already + return; + } + final Context parentContext = Kamon.currentContext(); + final RequestBuilder builder = ApacheHttpClientHelper.toRequestBuilder(request); + handler = ApacheHttpClientInstrumentation.httpClientInstrumentation().createHandler(builder, parentContext); + final Context ctx = parentContext.withEntry(Span.Key(), handler.span()); + scope = Kamon.storeContext(ctx); + request = handler.request(); + ((HasContext) request).setContext(ctx); + } + + @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class) + public static void onExit(@Advice.Return HttpResponse response, + @Advice.Thrown Throwable t, + @Advice.Local("handler") RequestHandler handler, + @Advice.Local("scope") Scope scope) { + if (scope == null) { + return; + } + ApacheHttpClientInstrumentation.processResponse(handler, response, t); + scope.close(); + } +} \ No newline at end of file diff --git a/instrumentation/kamon-apache-httpclient/src/main/java/kamon/instrumentation/apache/httpclient/UriRequestWithHandlerAdvisor.java b/instrumentation/kamon-apache-httpclient/src/main/java/kamon/instrumentation/apache/httpclient/UriRequestWithHandlerAdvisor.java new file mode 100644 index 000000000..a22f106a8 --- /dev/null +++ b/instrumentation/kamon-apache-httpclient/src/main/java/kamon/instrumentation/apache/httpclient/UriRequestWithHandlerAdvisor.java @@ -0,0 +1,43 @@ +package kamon.instrumentation.apache.httpclient; + +import org.apache.http.client.ResponseHandler; +import org.apache.http.client.methods.HttpUriRequest; + +import kamon.Kamon; +import kamon.context.Context; +import kamon.context.Storage.Scope; +import kamon.instrumentation.context.HasContext; +import kamon.instrumentation.http.HttpClientInstrumentation.RequestHandler; +import kamon.instrumentation.http.HttpMessage.RequestBuilder; +import kamon.trace.Span; +import kanela.agent.libs.net.bytebuddy.asm.Advice; + +public class UriRequestWithHandlerAdvisor { + + @Advice.OnMethodEnter(suppress = Throwable.class) + public static void onEnter( + @Advice.Argument(value = 0, readOnly = false) HttpUriRequest request, + @Advice.Argument(value = 1, readOnly = false) ResponseHandler resHandler, + @Advice.Local("handler") RequestHandler reqHandler, + @Advice.Local("scope") Scope scope) { + if (((HasContext) request).context().nonEmpty()) { + // Request has been instrumented already + return; + } + final Context parentContext = Kamon.currentContext(); + final RequestBuilder builder = ApacheHttpClientHelper.toRequestBuilder(request); + reqHandler = ApacheHttpClientInstrumentation.httpClientInstrumentation().createHandler(builder, parentContext); + resHandler = new ResponseHandlerProxy<>(reqHandler, resHandler, parentContext); + final Context ctx = parentContext.withEntry(Span.Key(), reqHandler.span()); + scope = Kamon.storeContext(ctx); + request = reqHandler.request(); + ((HasContext) request).setContext(ctx); + } + + @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class) + public static void onExit(@Advice.Local("scope") Scope scope) { + if (scope != null) { + scope.close(); + } + } +} \ No newline at end of file diff --git a/instrumentation/kamon-apache-httpclient/src/main/resources/reference.conf b/instrumentation/kamon-apache-httpclient/src/main/resources/reference.conf new file mode 100644 index 000000000..4e58dad87 --- /dev/null +++ b/instrumentation/kamon-apache-httpclient/src/main/resources/reference.conf @@ -0,0 +1,99 @@ +# ================================================== # +# kamon Apache HttpClient 2.0 client reference configuration # +# ================================================== # + +# Settings to control the HTTP Client instrumentation +# +# IMPORTANT: The entire configuration of the HTTP Client Instrumentation is based on the constructs provided by the +# Kamon Instrumentation Common library which will always fallback to the settings found under the +# "kamon.instrumentation.http-client.default" path. The default settings have been included here to make them easy to +# find and understand in the context of this project and commented out so that any changes to the default settings +# will actually have effect. +# +kamon.instrumentation.apache.httpclient { + + # + # Configuration for HTTP context propagation. + # + propagation { + + # Enables or disables HTTP context propagation on this HTTP client instrumentation. Please note that if + # propagation is disabled then some distributed tracing features will not be work as expected (e.g. Spans can + # be created and reported but will not be linked across boundaries nor take trace identifiers from tags). + #enabled = yes + + # HTTP propagation channel to b used by this instrumentation. Take a look at the kamon.propagation.http.default + # configuration for more details on how to configure the detault HTTP context propagation. + #channel = "default" + } + + tracing { + + # Enables HTTP request tracing. When enabled the instrumentation will create Spans for outgoing requests + # and finish them when the response is received from the server. + #enabled = yes + + # Enables collection of span metrics using the `span.processing-time` metric. + #span-metrics = on + + # Select which tags should be included as span and span metric tags. The possible options are: + # - span: the tag is added as a Span tag (i.e. using span.tag(...)) + # - metric: the tag is added a a Span metric tag (i.e. using span.tagMetric(...)) + # - off: the tag is not used. + # + tags { + + # Use the http.url tag. + #url = span + + # Use the http.method tag. + #method = metric + + # Use the http.status_code tag. + #status-code = metric + + # Copy tags from the context into the Spans with the specified purpouse. For example, to copy a customer_type + # tag from the context into the HTTP Client Span created by the instrumentation, the following configuration + # should be added: + # + # from-context { + # customer_type = span + # } + # + from-context { + + } + } + + operations { + + # The default operation name to be used when creating Spans to handle the HTTP client requests. The HTTP + # Client instrumentation will always try to use the HTTP Operation Name Generator configured below to get + # a name, but if it fails to generate it then this name will be used. + #default = "http.client.request" + + # FQCN for a HttpOperationNameGenerator implementation, or ony of the following shorthand forms: + # - hostname: Uses the request Host as the operation name. + # - method: Uses the request HTTP method as the operation name. + # + #name-generator = "method" + } + } + +} + +kanela { + modules { + apache-httpclient { + name = "Apache Http Client" + description = "Provides tracing of client calls made with the official Apache HttpClient library." + instrumentations = [ + "kamon.instrumentation.apache.httpclient.ApacheHttpClientInstrumentation" + ] + + within = [ + "org.apache.http..*" + ] + } + } +} \ No newline at end of file diff --git a/instrumentation/kamon-apache-httpclient/src/main/scala/kamon/instrumentation/apache/httpclient/ApacheHttpClientHelper.scala b/instrumentation/kamon-apache-httpclient/src/main/scala/kamon/instrumentation/apache/httpclient/ApacheHttpClientHelper.scala new file mode 100644 index 000000000..1f3a380ab --- /dev/null +++ b/instrumentation/kamon-apache-httpclient/src/main/scala/kamon/instrumentation/apache/httpclient/ApacheHttpClientHelper.scala @@ -0,0 +1,141 @@ +package kamon.instrumentation.apache.httpclient + +import kamon.instrumentation.http.HttpMessage +import org.slf4j.LoggerFactory +import org.apache.http.HttpHost +import org.apache.http.HttpRequest +import org.apache.http.client.methods.HttpUriRequest +import java.net.URI +import scala.util.{Try, Failure, Success} +import kamon.instrumentation.context.HasContext +import kamon.Kamon +import org.apache.http.HttpResponse + +class ApacheHttpClientHelper +object ApacheHttpClientHelper { + + private val _logger = LoggerFactory.getLogger(classOf[ApacheHttpClientHelper]) + + def toRequestBuilder( + httpHost: HttpHost, + request: HttpRequest + ): HttpMessage.RequestBuilder[HttpRequest] = + new RequestReader with HttpMessage.RequestBuilder[HttpRequest] { + val delegate = request + val uri = { + var parsedUri = getUri(request) + if (parsedUri != null && httpHost != null) { + parsedUri = getCompleteUri(httpHost, parsedUri) + } + parsedUri + } + + override def write(header: String, value: String): Unit = + delegate.addHeader(header, value) + + override def build(): HttpRequest = { + _logger.trace("Prepared request for instrumentation: {}", this) + return delegate + } + + override def toString(): String = s"Host=$host,Port=$port,Method=$method,Path=$path" + } + + def toRequestBuilder( + request: HttpUriRequest + ): HttpMessage.RequestBuilder[HttpUriRequest] = + new RequestReader with HttpMessage.RequestBuilder[HttpUriRequest] { + val uri = request.getURI + val delegate = request + + override def write(header: String, value: String): Unit = + delegate.addHeader(header, value) + + override def build(): HttpUriRequest = { + _logger.trace("Prepared request for instrumentation: {}", this) + return delegate.asInstanceOf[HttpUriRequest] + } + + override def toString(): String = s"Host=$host,Port=$port,Method=$method,Path=$path" + } + + def toResponse(response: HttpResponse): HttpMessage.Response = new HttpMessage.Response { + override def statusCode: Int = { + if (response == null || response.getStatusLine() == null) { + _logger.debug("Not able to retrieve status code from response") + return -1; + } + return response.getStatusLine().getStatusCode() + } + } + + def getUri(request: HttpRequest): URI = + Try(new URI(request.getRequestLine.getUri)) match { + case Failure(exception) => + _logger.error("Failed to construct URI from request", exception) + null + case Success(value) => value + } + + def getCompleteUri(host: HttpHost, uri: URI): URI = + Try( + new URI( + host.getSchemeName, + null, + host.getHostName, + host.getPort, + uri.getPath, + uri.getQuery, + uri.getFragment + ) + ) match { + case Failure(exception) => + _logger.error("Failed to construct URI from request", exception) + null + case Success(value) => value + } + + private trait RequestReader extends HttpMessage.Request { + def uri: URI + def delegate: HttpRequest + + override def host: String = { + if (uri != null) { + return uri.getHost + } + return null + } + + override def port: Int = { + if (uri != null) { + return uri.getPort + } + return 0 + } + + override def method: String = delegate.getRequestLine.getMethod + + override def path: String = { + if (uri != null) { + return uri.getPath + } + return null + } + + override def read(header: String): Option[String] = + Some(delegate.getLastHeader(header).getValue) + + override def readAll(): Map[String, String] = + delegate.getAllHeaders + .map(header => (header.getName, header.getValue)) + .toMap + + override def url: String = { + if (uri != null) { + return uri.toString + } + return null + } + + } +} diff --git a/instrumentation/kamon-apache-httpclient/src/main/scala/kamon/instrumentation/apache/httpclient/ApacheHttpClientInstrumentation.scala b/instrumentation/kamon-apache-httpclient/src/main/scala/kamon/instrumentation/apache/httpclient/ApacheHttpClientInstrumentation.scala new file mode 100644 index 000000000..60660c59f --- /dev/null +++ b/instrumentation/kamon-apache-httpclient/src/main/scala/kamon/instrumentation/apache/httpclient/ApacheHttpClientInstrumentation.scala @@ -0,0 +1,118 @@ +package kamon.instrumentation.apache.httpclient + +import kanela.agent.api.instrumentation.InstrumentationBuilder +import kanela.agent.libs.net.bytebuddy.matcher.ElementMatchers._ +import kamon.instrumentation.context.HasContext +import org.apache.http.client.methods.HttpUriRequest +import org.apache.http.protocol.HttpContext +import org.apache.http.client.ResponseHandler +import org.apache.http.HttpHost +import org.apache.http.HttpRequest +import kamon.instrumentation.http.HttpClientInstrumentation +import kamon.instrumentation.http.HttpClientInstrumentation.RequestHandler +import kamon.Kamon +import org.apache.http.HttpResponse + +class ApacheHttpClientInstrumentation extends InstrumentationBuilder { + + onSubTypesOf("org.apache.http.HttpRequest", "org.apache.http.client.methods.HttpUriRequest") + .mixin(classOf[HasContext.Mixin]) + + onSubTypesOf("org.apache.http.client.HttpClient") + .advise( + method("execute") + .and(not(isAbstract())) + .and(takesArguments(1)) + .and(withArgument(0, classOf[HttpUriRequest])), + classOf[UriRequestAdvisor] + ) + .advise( + method("execute") + .and(not(isAbstract())) + .and(takesArguments(2)) + .and(withArgument(0, classOf[HttpUriRequest])) + .and(withArgument(1, classOf[HttpContext])), + classOf[UriRequestAdvisor] + ) + .advise( + method("execute") + .and(not(isAbstract())) + .and(takesArguments(2)) + .and(withArgument(0, classOf[HttpUriRequest])) + .and(withArgument(1, classOf[ResponseHandler[_]])), + classOf[UriRequestWithHandlerAdvisor] + ) + .advise( + method("execute") + .and(not(isAbstract())) + .and(takesArguments(3)) + .and(withArgument(0, classOf[HttpUriRequest])) + .and(withArgument(1, classOf[ResponseHandler[_]])) + .and(withArgument(2, classOf[HttpContext])), + classOf[UriRequestWithHandlerAdvisor] + ) + .advise( + method("execute") + .and(not(isAbstract())) + .and(takesArguments(2)) + .and(withArgument(0, classOf[HttpHost])) + .and(withArgument(1, classOf[HttpRequest])), + classOf[RequestAdvisor] + ) + .advise( + method("execute") + .and(not(isAbstract())) + .and(takesArguments(3)) + .and(withArgument(0, classOf[HttpHost])) + .and(withArgument(1, classOf[HttpRequest])) + .and(withArgument(2, classOf[HttpContext])), + classOf[RequestAdvisor] + ) + .advise( + method("execute") + .and(not(isAbstract())) + .and(takesArguments(3)) + .and(withArgument(0, classOf[HttpHost])) + .and(withArgument(1, classOf[HttpRequest])) + .and(withArgument(2, classOf[ResponseHandler[_]])), + classOf[RequestWithHandlerAdvisor] + ) + .advise( + method("execute") + .and(not(isAbstract())) + .and(takesArguments(4)) + .and(withArgument(0, classOf[HttpHost])) + .and(withArgument(1, classOf[HttpRequest])) + .and(withArgument(2, classOf[HttpContext])) + .and(withArgument(3, classOf[ResponseHandler[_]])), + classOf[RequestWithHandlerAdvisor] + ) + +} + +object ApacheHttpClientInstrumentation { + + Kamon.onReconfigure(_ => + ApacheHttpClientInstrumentation.rebuildHttpClientInstrumentation(): Unit + ) + + @volatile var httpClientInstrumentation: HttpClientInstrumentation = + rebuildHttpClientInstrumentation() + + private[httpclient] def rebuildHttpClientInstrumentation(): HttpClientInstrumentation = { + val httpClientConfig = + Kamon.config().getConfig("kamon.instrumentation.apache.httpclient") + httpClientInstrumentation = + HttpClientInstrumentation.from(httpClientConfig, "apache.httpclient") + return httpClientInstrumentation + } + + def processResponse(handler: RequestHandler[_], response: HttpResponse, t: Throwable): Unit = { + if (t != null) { + handler.span.fail(t) + } else { + handler.processResponse(ApacheHttpClientHelper.toResponse(response)) + } + } + +} diff --git a/instrumentation/kamon-apache-httpclient/src/test/resources/application.conf b/instrumentation/kamon-apache-httpclient/src/test/resources/application.conf new file mode 100644 index 000000000..725234095 --- /dev/null +++ b/instrumentation/kamon-apache-httpclient/src/test/resources/application.conf @@ -0,0 +1,18 @@ +kamon { + trace.sampler = "always" +} + +kanela { + # debug-mode = true + # log-level = "DEBUG" +} + +kamon.instrumentation.apache.httpclient { + tracing { + operations { + mappings { + "/custom-operation-name" = "named-from-config" + } + } + } +} diff --git a/instrumentation/kamon-apache-httpclient/src/test/resources/logback.xml b/instrumentation/kamon-apache-httpclient/src/test/resources/logback.xml new file mode 100644 index 000000000..05a9c6c99 --- /dev/null +++ b/instrumentation/kamon-apache-httpclient/src/test/resources/logback.xml @@ -0,0 +1,17 @@ + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + diff --git a/instrumentation/kamon-apache-httpclient/src/test/scala/kamon/instrumentation/apache/httpclient/HttpRequestSpec.scala b/instrumentation/kamon-apache-httpclient/src/test/scala/kamon/instrumentation/apache/httpclient/HttpRequestSpec.scala new file mode 100644 index 000000000..729d65e58 --- /dev/null +++ b/instrumentation/kamon-apache-httpclient/src/test/scala/kamon/instrumentation/apache/httpclient/HttpRequestSpec.scala @@ -0,0 +1,176 @@ +package kamon.instrumentation.apache.httpclient + +import com.dimafeng.testcontainers.{MockServerContainer, ForAllTestContainer} + +import kamon.Kamon +import kamon.tag.Lookups._ +import kamon.testkit._ +import kamon.instrumentation.apache.httpclient.util._ +import org.apache.http.client.HttpClient +import org.apache.http.impl.client.HttpClients +import org.scalatest.concurrent.Eventually +import org.scalatest.matchers.should.Matchers +import org.scalatest.time.SpanSugar +import org.scalatest.wordspec.AnyWordSpec +import org.scalatest.OptionValues +import java.net.URI +import org.mockserver.client.MockServerClient +import org.mockserver.model.HttpRequest.{request => mockRequest} +import org.mockserver.model.HttpResponse.{response => mockResponse} +import org.slf4j.LoggerFactory +import org.mockserver.mock.Expectation +import org.apache.http.message.BasicHttpRequest +import org.apache.http.HttpHost +import org.apache.http.protocol.HttpContext +import org.apache.http.protocol.BasicHttpContext +import org.apache.http.client.protocol.ClientContext +import org.apache.http.client.protocol.HttpClientContext +import org.apache.http.impl.client.BasicCookieStore +import org.mockserver.model.HttpResponse + +class HttpRequestSpec + extends AnyWordSpec + with Matchers + with Eventually + with SpanSugar + with Reconfigure + with OptionValues + with TestSpanReporter + with InitAndStopKamonAfterAll + with ForAllTestContainer { + + private val _logger = + LoggerFactory.getLogger(classOf[HttpRequestSpec]) + + private lazy val host = HttpHost.create(container.endpoint) + + "The apache httpclient taking HttpRequest" should { + "create client span when using execute(...)" in { + clientExpectation.simpleGetExpectation + val client = HttpClients.createMinimal() + val path = clientExpectation.simpleGetPath + val target = s"${container.endpoint}$path" + val request = new BasicHttpRequest("GET", path) + val response = client.execute(host, request) + + eventually(timeout(10 seconds)) { + val span = testSpanReporter().nextSpan().value + _logger.debug("Received Span: {}", span) + span.operationName shouldBe "GET" + span.tags.get(plain("http.url")) shouldBe target + span.metricTags.get(plain("component")) shouldBe "apache.httpclient" + span.metricTags.get(plain("http.method")) shouldBe "GET" + } + } + + "replace operation name from config" in { + clientExpectation.customOptNameExpectation + val client = HttpClients.createMinimal() + val path = clientExpectation.customOptNamePath + val target = s"${container.endpoint}$path" + val request = new BasicHttpRequest("POST", path) + val response = client.execute(host, request) + + eventually(timeout(10 seconds)) { + val span = testSpanReporter().nextSpan().value + _logger.debug("Received Span: {}", span) + span.operationName shouldBe "named-from-config" + span.tags.get(plain("http.url")) shouldBe target + span.metricTags.get(plain("component")) shouldBe "apache.httpclient" + span.metricTags.get(plain("http.method")) shouldBe "POST" + } + } + + "append current context into HTTP headers" in { + clientExpectation.checkHeadersExpectation + val client = HttpClients.createMinimal() + val path = clientExpectation.checkHeadersPath + val target = s"${container.endpoint}$path" + val testTag = "custom.tag" + val testTagVal = "haha! gotcha" + val request = new BasicHttpRequest("GET", path) + val response = Kamon.runWithContextTag(testTag, testTagVal) { + request.addHeader("X-Test-Header", "check value") + client.execute(host, request, new StringResponseHandler()) + } + val headerMap: Map[String, String] = request.getAllHeaders + .map(header => (header.getName, header.getValue)) + .toMap + _logger.debug("Request headers: {}", headerMap) + + eventually(timeout(10 seconds)) { + val span = testSpanReporter().nextSpan().value + _logger.debug("Received Span: {}", span) + headerMap.keys.toList should contain allOf ( + "context-tags", + "X-Test-Header", + "X-B3-TraceId", + "X-B3-SpanId", + "X-B3-Sampled" + ) + + headerMap.get("X-Test-Header").value shouldBe "check value" + headerMap.get("context-tags").value shouldBe "custom.tag=haha! gotcha;upstream.name=kamon-application;" + } + } + + "mark spans as errors when request fails" in { + clientExpectation.test500Expectation + val client = HttpClients.createMinimal() + val path = clientExpectation.test500Path + val target = s"${container.endpoint}$path" + val ctx = new BasicHttpContext() + ctx.setAttribute(HttpClientContext.COOKIE_STORE, new BasicCookieStore()) + val request = new BasicHttpRequest("GET", path) + val response = client.execute(host, request, ctx) + + eventually(timeout(10 seconds)) { + val span = testSpanReporter().nextSpan().value + _logger.debug("Received Span: {}", span) + span.operationName shouldBe "GET" + span.tags.get(plain("http.url")) shouldBe target + span.metricTags.get(plain("component")) shouldBe "apache.httpclient" + span.metricTags.get(plain("http.method")) shouldBe "GET" + span.metricTags.get(plainBoolean("error")) shouldBe true + span.metricTags.get(plainLong("http.status_code")) shouldBe 500 + span.hasError shouldBe true + } + } + + "not mark spans as error when response handler throws" in { + clientExpectation.failingResponseHandlerExpectation + val client = HttpClients.createMinimal() + val path = clientExpectation.failingResponseHandlerPath + val target = s"${container.endpoint}$path" + val request = new BasicHttpRequest("GET", path) + assertThrows[RuntimeException] { + client.execute(host, request, new ErrorThrowingHandler()) + } + + eventually(timeout(10 seconds)) { + val span = testSpanReporter().nextSpan().value + _logger.debug("Received Span: {}", span) + span.operationName shouldBe "GET" + span.tags.get(plain("http.url")) shouldBe target + span.metricTags.get(plain("component")) shouldBe "apache.httpclient" + span.metricTags.get(plain("http.method")) shouldBe "GET" + span.metricTags.get(plainLong("http.status_code")) shouldBe 204 + } + } + + } + + override val container: MockServerContainer = MockServerContainer() + lazy val clientExpectation: MockServerExpectations = new MockServerExpectations("localhost", container.serverPort) + + override protected def beforeAll(): Unit = { + super.beforeAll() + container.start() + } + + override protected def afterAll(): Unit = { + container.stop() + super.afterAll() + } + +} diff --git a/instrumentation/kamon-apache-httpclient/src/test/scala/kamon/instrumentation/apache/httpclient/HttpUriRequestSpec.scala b/instrumentation/kamon-apache-httpclient/src/test/scala/kamon/instrumentation/apache/httpclient/HttpUriRequestSpec.scala new file mode 100644 index 000000000..1eadbed25 --- /dev/null +++ b/instrumentation/kamon-apache-httpclient/src/test/scala/kamon/instrumentation/apache/httpclient/HttpUriRequestSpec.scala @@ -0,0 +1,171 @@ +package kamon.instrumentation.apache.httpclient + +import com.dimafeng.testcontainers.{MockServerContainer, ForAllTestContainer} + +import kamon.Kamon +import kamon.tag.Lookups._ +import kamon.testkit._ +import kamon.instrumentation.apache.httpclient.util._ +import org.apache.http.client.HttpClient +import org.apache.http.impl.client.HttpClients +import org.scalatest.concurrent.Eventually +import org.scalatest.matchers.should.Matchers +import org.scalatest.time.SpanSugar +import org.scalatest.wordspec.AnyWordSpec +import org.scalatest.OptionValues +import java.net.URI +import org.mockserver.client.MockServerClient +import org.mockserver.model.HttpRequest.{request => mockRequest} +import org.mockserver.model.HttpResponse.{response => mockResponse} +import org.slf4j.LoggerFactory +import org.apache.http.client.methods.HttpGet +import org.mockserver.mock.Expectation +import _root_.org.apache.http.message.BasicHttpRequest +import org.apache.http.HttpHost +import org.apache.http.protocol.HttpContext +import org.apache.http.protocol.BasicHttpContext +import org.apache.http.client.protocol.ClientContext +import org.apache.http.client.protocol.HttpClientContext +import org.apache.http.impl.client.BasicCookieStore +import org.mockserver.model.HttpResponse +import org.apache.http.client.methods.HttpPost + +class HttpUriRequestSpec + extends AnyWordSpec + with Matchers + with Eventually + with SpanSugar + with Reconfigure + with OptionValues + with TestSpanReporter + with InitAndStopKamonAfterAll + with ForAllTestContainer { + + private val _logger = + LoggerFactory.getLogger(classOf[HttpUriRequestSpec]) + + "The apache httpclient taking HttpUriRequest" should { + "create client span when using execute(...)" in { + clientExpectation.simpleGetExpectation + val client = HttpClients.createMinimal() + val target = s"${container.endpoint}${clientExpectation.simpleGetPath}" + val request = new HttpGet(target) + val response = client.execute(request) + + eventually(timeout(10 seconds)) { + val span = testSpanReporter().nextSpan().value + _logger.debug("Received Span: {}", span) + span.operationName shouldBe "GET" + span.tags.get(plain("http.url")) shouldBe target + span.metricTags.get(plain("component")) shouldBe "apache.httpclient" + span.metricTags.get(plain("http.method")) shouldBe "GET" + } + } + + "replace operation name from config" in { + clientExpectation.customOptNameExpectation + val client = HttpClients.createMinimal() + val target = s"${container.endpoint}${clientExpectation.customOptNamePath}" + val request = new HttpPost(target) + val response = client.execute(request) + + eventually(timeout(10 seconds)) { + val span = testSpanReporter().nextSpan().value + _logger.debug("Received Span: {}", span) + span.operationName shouldBe "named-from-config" + span.tags.get(plain("http.url")) shouldBe target + span.metricTags.get(plain("component")) shouldBe "apache.httpclient" + span.metricTags.get(plain("http.method")) shouldBe "POST" + } + } + + "append current context into HTTP headers" in { + clientExpectation.checkHeadersExpectation + val client = HttpClients.createMinimal() + val target = s"${container.endpoint}${clientExpectation.checkHeadersPath}" + val testTag = "custom.tag" + val testTagVal = "haha! gotcha" + val request = new HttpGet(target) + val response = Kamon.runWithContextTag(testTag, testTagVal) { + request.addHeader("X-Test-Header", "check value") + client.execute(request, new StringResponseHandler()) + } + val headerMap: Map[String, String] = request.getAllHeaders + .map(header => (header.getName, header.getValue)) + .toMap + _logger.debug("Request headers: {}", headerMap) + + eventually(timeout(10 seconds)) { + val span = testSpanReporter().nextSpan().value + _logger.debug("Received Span: {}", span) + headerMap.keys.toList should contain allOf ( + "context-tags", + "X-Test-Header", + "X-B3-TraceId", + "X-B3-SpanId", + "X-B3-Sampled" + ) + + headerMap.get("X-Test-Header").value shouldBe "check value" + headerMap.get("context-tags").value shouldBe "custom.tag=haha! gotcha;upstream.name=kamon-application;" + } + } + + "mark spans as errors when request fails" in { + clientExpectation.test500Expectation + val client = HttpClients.createMinimal() + val target = s"${container.endpoint}${clientExpectation.test500Path}" + val ctx = new BasicHttpContext() + ctx.setAttribute(HttpClientContext.COOKIE_STORE, new BasicCookieStore()) + val request = new HttpGet(target) + val response = client.execute(request, ctx) + + eventually(timeout(10 seconds)) { + val span = testSpanReporter().nextSpan().value + _logger.debug("Received Span: {}", span) + span.operationName shouldBe "GET" + span.tags.get(plain("http.url")) shouldBe target + span.metricTags.get(plain("component")) shouldBe "apache.httpclient" + span.metricTags.get(plain("http.method")) shouldBe "GET" + span.metricTags.get(plainBoolean("error")) shouldBe true + span.metricTags.get(plainLong("http.status_code")) shouldBe 500 + span.hasError shouldBe true + } + } + + "not mark spans as error when response handler throws" in { + clientExpectation.failingResponseHandlerExpectation + val client = HttpClients.createMinimal() + val target = s"${container.endpoint}${clientExpectation.failingResponseHandlerPath}" + val request = new HttpGet(target) + assertThrows[RuntimeException] { + client.execute(request, new ErrorThrowingHandler()) + } + + eventually(timeout(10 seconds)) { + val span = testSpanReporter().nextSpan().value + _logger.debug("Received Span: {}", span) + span.operationName shouldBe "GET" + span.tags.get(plain("http.url")) shouldBe target + span.metricTags.get(plain("component")) shouldBe "apache.httpclient" + span.metricTags.get(plain("http.method")) shouldBe "GET" + span.metricTags.get(plainLong("http.status_code")) shouldBe 204 + } + } + + } + + override val container: MockServerContainer = MockServerContainer() + lazy val clientExpectation: MockServerExpectations = new MockServerExpectations("localhost", container.serverPort) + + override protected def beforeAll(): Unit = { + super.beforeAll() + container.start() + } + + override protected def afterAll(): Unit = { + container.stop() + super.afterAll() + } + +} diff --git a/instrumentation/kamon-apache-httpclient/src/test/scala/kamon/instrumentation/apache/httpclient/util/MockServerExpectations.scala b/instrumentation/kamon-apache-httpclient/src/test/scala/kamon/instrumentation/apache/httpclient/util/MockServerExpectations.scala new file mode 100644 index 000000000..11be2952b --- /dev/null +++ b/instrumentation/kamon-apache-httpclient/src/test/scala/kamon/instrumentation/apache/httpclient/util/MockServerExpectations.scala @@ -0,0 +1,45 @@ +package kamon.instrumentation.apache.httpclient.util + +import org.mockserver.client.MockServerClient +import org.mockserver.model.HttpTemplate +import org.mockserver.model.HttpRequest.{request => mockRequest} +import org.mockserver.model.HttpResponse.{response => mockResponse} +import org.mockserver.model.Delay +import java.util.HashMap +import org.mockserver.model.Header +import org.slf4j.LoggerFactory +import org.mockserver.model.Parameters + +class MockServerExpectations(private val host: String, private val port: Int) { + private val _logger = LoggerFactory.getLogger(classOf[MockServerExpectations]) + + private[util] def client: MockServerClient = new MockServerClient(host, port) + + def simpleGetPath: String = "/simple-get" + def simpleGetExpectation: Unit = client.when(mockRequest().withMethod("GET").withPath(simpleGetPath)).respond( + mockResponse().withBody("simple-get says hi!!") + ) + + def customOptNamePath: String = "/custom-operation-name" + def customOptNameExpectation: Unit = client.when( + mockRequest().withMethod("POST").withPath(customOptNamePath) + ).respond(mockResponse().withBody("got posted")) + + def checkHeadersPath: String = "/check-headers?dummy=true¬so=false" + def checkHeadersExpectation: Unit = client.when( + mockRequest().withMethod("GET").withPath("/check-headers").withQueryStringParameter( + "dummy", + "true" + ).withQueryStringParameter("notso", "false") + ).respond(mockResponse().withBody("check your headers")) + + def test500Path: String = "/test-500-error" + def test500Expectation: Unit = { + client.when(mockRequest().withPath(test500Path)).respond(mockResponse().withStatusCode(500)) + } + + def failingResponseHandlerPath: String = "/failing-handler" + def failingResponseHandlerExpectation: Unit = { + client.when(mockRequest().withPath(failingResponseHandlerPath)).respond(mockResponse().withStatusCode(204)) + } +} diff --git a/instrumentation/kamon-apache-httpclient/src/test/scala/kamon/instrumentation/apache/httpclient/util/ResponseHandlers.scala b/instrumentation/kamon-apache-httpclient/src/test/scala/kamon/instrumentation/apache/httpclient/util/ResponseHandlers.scala new file mode 100644 index 000000000..2e1657aba --- /dev/null +++ b/instrumentation/kamon-apache-httpclient/src/test/scala/kamon/instrumentation/apache/httpclient/util/ResponseHandlers.scala @@ -0,0 +1,25 @@ +package kamon.instrumentation.apache.httpclient.util + +import org.apache.http.client.ResponseHandler +import org.apache.http.HttpResponse +import org.apache.http.util.EntityUtils + +class StringResponseHandler extends ResponseHandler[String] { + + def handleResponse(response: HttpResponse): String = { + val code = response.getStatusLine().getStatusCode() + if (200 until 300 contains code) { + return EntityUtils.toString(response.getEntity()) + } + return null + } + +} + +class ErrorThrowingHandler extends ResponseHandler[String] { + + def handleResponse(response: HttpResponse): String = { + throw new RuntimeException("Dummy Exception") + } + +}