diff --git a/build.sbt b/build.sbt index ac33e64f9..4c356f858 100644 --- a/build.sbt +++ b/build.sbt @@ -129,6 +129,7 @@ lazy val instrumentation = (project in file("instrumentation")) `kamon-akka-http`, `kamon-play`, `kamon-okhttp`, + `kamon-armeria` ) @@ -423,6 +424,21 @@ lazy val `kamon-okhttp` = (project in file("instrumentation/kamon-okhttp")) ).dependsOn(`kamon-core`, `kamon-executors`, `kamon-testkit` % "test") +lazy val `kamon-armeria` = (project in file("instrumentation/kamon-armeria")) + .disablePlugins(AssemblyPlugin) + .enablePlugins(JavaAgent) + .settings(instrumentationSettings) + .settings( + libraryDependencies ++= Seq( + kanelaAgent % "provided", + "com.linecorp.armeria" % "armeria" % "1.0.0" % "provided", + + scalatest % "test", + okHttp % "test", + logbackClassic % "test" + ) + ).dependsOn(`kamon-instrumentation-common`, `kamon-testkit` % "test") + /** * Reporters */ @@ -637,4 +653,5 @@ val `kamon-bundle` = (project in file("bundle/kamon-bundle")) `kamon-akka-http` % "shaded", `kamon-play` % "shaded", `kamon-okhttp` % "shaded", - ) \ No newline at end of file + `kamon-armeria` % "shaded" +) diff --git a/instrumentation/kamon-armeria/src/main/resources/reference.conf b/instrumentation/kamon-armeria/src/main/resources/reference.conf new file mode 100644 index 000000000..3c5370aaf --- /dev/null +++ b/instrumentation/kamon-armeria/src/main/resources/reference.conf @@ -0,0 +1,261 @@ +# =================================== # +# kamon-armeria reference configuration # +# =================================== # + + +kamon.instrumentation.armeria { + + # Settings to control the HTTP Server instrumentation. + # + # IMPORTANT: Besides the "initial-operation-name" and "unhandled-operation-name" settings, the entire configuration of + # the HTTP Server 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-server.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. + # + http-server { + + # + # Configuration for HTTP context propagation. + # + propagation { + + # Enables or disables HTTP context propagation on this HTTP server 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" + + + } + + + # + # Configuration for HTTP server metrics collection. + # + metrics { + + # Enables collection of HTTP server metrics. When enabled the following metrics will be collected, assuming + # that the instrumentation is fully compliant: + # + # - http.server.requets + # - http.server.request.active + # - http.server.request.size + # - http.server.response.size + # - http.server.connection.lifetime + # - http.server.connection.usage + # - http.server.connection.open + # + # All metrics have at least three tags: component, interface and port. Additionally, the http.server.requests + # metric will also have a status_code tag with the status code group (1xx, 2xx and so on). + # + #enabled = yes + } + + + # + # Configuration for HTTP request tracing. + # + tracing { + + # Enables HTTP request tracing. When enabled the instrumentation will create Spans for incoming requests + # and finish them when the response is sent back to the clients. + #enabled = yes + + # Select a context tag that provides a preferred trace identifier. The preferred trace identifier will be used + # only if all these conditions are met: + # - the context tag is present. + # - there is no parent Span on the incoming context (i.e. this is the first service on the trace). + # - the identifier is valid in accordance to the identity provider. + #preferred-trace-id-tag = "none" + + # 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 Server Span crekamon.trace.sampler = alwaysated by the instrumentation, the following configuration + # should be added: + # + # from-context { + # customer_type = span + # } + # + from-context { + + } + } + + # Controls writing trace and span identifiers to HTTP response headers sent by the instrumented servers. The + # configuration can be set to either "none" to disable writing the identifiers on the response headers or to + # the header name to be used when writing the identifiers. + response-headers { + + # HTTP response header name for the trace identifier, or "none" to disable it. + #trace-id = "trace-id" + + # HTTP response header name for the server span identifier, or "none" to disable it. + #span-id = none + } + + # Custom mappings between routes and operation names. + operations { + + # The default operation name to be used when creating Spans to handle the HTTP server requests. In most + # cases it is not possible to define an operation name right at the moment of starting the HTTP server Span + # and in those cases, this operation name will be initially assigned to the Span. Instrumentation authors + # should do their best effort to provide a suitable operation name or make use of the "mappings" facilities. + default = "http.server.request" + + # The operation name to be assigned when an application cannot find any route/endpoint/controller to handle + # a given request. Depending on the instrumented framework, it might be possible to apply this operation + # name automatically or not, check the frameworks' instrumentation docs for more details. + unhandled = "unhandled" + + # FQCN for a HttpOperationNameGenerator implementation, or ony of the following shorthand forms: + # - default: Uses the set default operation name + # - method: Uses the request HTTP method as the operation name. + # + name-generator = "kamon.armeria.instrumentation.server.KamonArmeriaOperationNameGenerator" + + # Provides custom mappings from HTTP paths into operation names. Meant to be used in cases where the bytecode + # instrumentation is not able to provide a sensible operation name that is free of high cardinality values. + # For example, with the following configuration: + # mappings { + # "/organization/*/user/*/profile" = "/organization/:orgID/user/:userID/profile" + # "/events/*/rsvps" = "EventRSVPs" + # } + # + # Requests to "/organization/3651/user/39652/profile" and "/organization/22234/user/54543/profile" will have + # the same operation name "/organization/:orgID/user/:userID/profile". + # + # Similarly, requests to "/events/aaa-bb-ccc/rsvps" and "/events/1234/rsvps" will have the same operation + # name "EventRSVPs". + # + # The patterns are expressed as globs and the operation names are free form. + # + mappings { + + } + } + } + } + + # 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. + # + http-client { + # + # Configuration for HTTP context propagation. + # + propagation { + + # Enables or disables HTTP context propagation on this HTTP server 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 Server 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 bellow 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 = "kamon.armeria.instrumentation.client.KamonArmeriaOperationNameGenerator" + } + } + } +} + +kanela { + show-banner = false + modules { + armeria-http-server { + name = "Armeria http server Instrumentation" + stoppable = true + instrumentations = [ + "kamon.armeria.instrumentation.server.ArmeriaHttpServerInstrumentation" + ] + within = [ "io.netty..*", "com.linecorp.armeria..*"] + } + armeria-http-client { + name = "Armeria http client Instrumentation" + stoppable = true + instrumentations = [ + "kamon.armeria.instrumentation.client.ArmeriaHttpClientInstrumentation" + ] + within = [ "com.linecorp.armeria.client..*"] + } + } +} diff --git a/instrumentation/kamon-armeria/src/main/scala/kamon/armeria/instrumentation/BaseKamonArmeriaOperationNameGenerator.scala b/instrumentation/kamon-armeria/src/main/scala/kamon/armeria/instrumentation/BaseKamonArmeriaOperationNameGenerator.scala new file mode 100644 index 000000000..2bf89eb9a --- /dev/null +++ b/instrumentation/kamon-armeria/src/main/scala/kamon/armeria/instrumentation/BaseKamonArmeriaOperationNameGenerator.scala @@ -0,0 +1,49 @@ +/* ========================================================================================= + * Copyright © 2013-2020 the kamon project + * + * Licensed under the Apache License, Version 2.0 (the "License") you may not use this file + * except in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + * ========================================================================================= + */ + +package kamon.armeria.instrumentation + +import kamon.instrumentation.http.HttpMessage.Request +import kamon.instrumentation.http.HttpOperationNameGenerator + +import scala.collection.concurrent.TrieMap + +trait BaseKamonArmeriaOperationNameGenerator extends HttpOperationNameGenerator { + + private val localCache = TrieMap.empty[String, String] + private val normalizePattern = """\$([^<]+)<[^>]+>""".r + + def name(request: Request): Option[String] = + Some( + localCache.getOrElseUpdate(key(request), { + // Convert paths of form GET /foo/bar/$paramname/blah to foo.bar.paramname.blah.get + val normalisedPath = normalisePath(request.path) + name(request, normalisedPath) + }) + ) + + protected def name(request: Request, normalisedPath: String): String + + protected def key(request: Request): String + + private def normalisePath(path: String) = { + val p = normalizePattern.replaceAllIn(path, "$1").replace('/', '.').dropWhile(_ == '.') + val normalisedPath = { + if (p.lastOption.exists(_ != '.')) s"$p." + else p + } + normalisedPath + } +} diff --git a/instrumentation/kamon-armeria/src/main/scala/kamon/armeria/instrumentation/client/ArmeriaHttpClientInstrumentation.scala b/instrumentation/kamon-armeria/src/main/scala/kamon/armeria/instrumentation/client/ArmeriaHttpClientInstrumentation.scala new file mode 100644 index 000000000..900f23b97 --- /dev/null +++ b/instrumentation/kamon-armeria/src/main/scala/kamon/armeria/instrumentation/client/ArmeriaHttpClientInstrumentation.scala @@ -0,0 +1,66 @@ +/* ========================================================================================= + * Copyright © 2013-2020 the kamon project + * + * Licensed under the Apache License, Version 2.0 (the "License") you may not use this file + * except in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + * ========================================================================================= + */ + +package kamon.armeria.instrumentation.client + +import com.linecorp.armeria.client.{ClientBuilder, ClientRequestContext, DecoratingHttpClientFunction, HttpClient} +import com.linecorp.armeria.common.logging.RequestLog +import com.linecorp.armeria.common.{HttpRequest, HttpResponse} +import kamon.Kamon +import kamon.armeria.instrumentation.client.ArmeriaHttpClientTracing.{getRequestBuilder, toKamonResponse} +import kamon.armeria.instrumentation.interop.JavaInterop +import kamon.instrumentation.http.HttpClientInstrumentation +import kanela.agent.api.instrumentation.InstrumentationBuilder +import kanela.agent.libs.net.bytebuddy.asm.Advice + +import scala.util.control.NonFatal + +class ArmeriaHttpClientInstrumentation extends InstrumentationBuilder { + onType("com.linecorp.armeria.client.ClientBuilder") + .advise(isConstructor, classOf[ArmeriaHttpClientBuilderAdvisor]) +} + +class ArmeriaHttpClientBuilderAdvisor + +object ArmeriaHttpClientBuilderAdvisor { + @Advice.OnMethodExit(suppress = classOf[Throwable]) + def addKamonDecorator(@Advice.This builder: ClientBuilder): Unit = { + builder.decorator(new KamonArmeriaDecoratingFunction()) + } +} + + +class KamonArmeriaDecoratingFunction extends DecoratingHttpClientFunction with JavaInterop { + private val httpClientConfig = Kamon.config.getConfig("kamon.instrumentation.armeria.http-client") + private val instrumentation = HttpClientInstrumentation.from(httpClientConfig, "armeria-http-client") + + override def execute(delegate: HttpClient, ctx: ClientRequestContext, req: HttpRequest): HttpResponse = { + val requestHandler = instrumentation.createHandler(getRequestBuilder(req), Kamon.currentContext) + lazy val processResponseFn = + ((reqLog: RequestLog) => requestHandler.processResponse(toKamonResponse(reqLog))).asJavaConsumer + try { + ctx.log() + .whenComplete() + .thenAccept(processResponseFn) + delegate.execute(ctx, requestHandler.request) + } catch { + case NonFatal(error) => + requestHandler.span.fail(error) + requestHandler.span.finish() + throw error + } + } +} + diff --git a/instrumentation/kamon-armeria/src/main/scala/kamon/armeria/instrumentation/client/ArmeriaHttpClientTracing.scala b/instrumentation/kamon-armeria/src/main/scala/kamon/armeria/instrumentation/client/ArmeriaHttpClientTracing.scala new file mode 100644 index 000000000..30e815e25 --- /dev/null +++ b/instrumentation/kamon-armeria/src/main/scala/kamon/armeria/instrumentation/client/ArmeriaHttpClientTracing.scala @@ -0,0 +1,77 @@ +/* ========================================================================================= + * Copyright © 2013-2020 the kamon project + * + * Licensed under the Apache License, Version 2.0 (the "License") you may not use this file + * except in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + * ========================================================================================= + */ + +package kamon.armeria.instrumentation.client + +import com.linecorp.armeria.common.HttpRequest +import com.linecorp.armeria.common.logging.RequestLog +import kamon.context.HttpPropagation.HeaderWriter +import kamon.instrumentation.http.HttpMessage + +import scala.collection.immutable.Map +import scala.collection.{JavaConverters, mutable} + +object ArmeriaHttpClientTracing { + + def getRequestBuilder(request: HttpRequest): HttpMessage.RequestBuilder[HttpRequest] = new HttpMessage.RequestBuilder[HttpRequest]() { + private val _headers = mutable.Map[String, String]() + + override def read(header: String): Option[String] = Option(request.headers().get(header)) + + override def readAll: Map[String, String] = { + JavaConverters + .asScalaIteratorConverter(request.headers().iterator()) + .asScala + .map(entry => (entry.getKey.toString, entry.getValue)) + .toMap + + } + + override def url: String = request.uri().toString + + override def path: String = request.uri().getPath + + override def method: String = request.method().name() + + override def host: String = request.uri().getHost + + override def port: Int = request.uri().getPort + + override def write(header: String, value: String): Unit = { + _headers += (header -> value) + } + + override def build: HttpRequest = { + val newHeadersMap = request.headers.toBuilder + _headers.foreach { case (key, value) => newHeadersMap.add(key, value) } + request.withHeaders(newHeadersMap) + } + } + + def toKamonResponse(reqLog: RequestLog): HttpMessage.Response = new HttpMessage.Response() { + override def statusCode: Int = reqLog.responseHeaders().status().code() + } + + trait HeaderHandler extends HeaderWriter { + private val _headers = mutable.Map[String, String]() + + override def write(header: String, value: String): Unit = { + _headers += (header -> value) + } + + def headers: mutable.Map[String, String] = _headers + } + +} diff --git a/instrumentation/kamon-armeria/src/main/scala/kamon/armeria/instrumentation/client/KamonArmeriaOperationNameGenerator.scala b/instrumentation/kamon-armeria/src/main/scala/kamon/armeria/instrumentation/client/KamonArmeriaOperationNameGenerator.scala new file mode 100644 index 000000000..4b07dafd8 --- /dev/null +++ b/instrumentation/kamon-armeria/src/main/scala/kamon/armeria/instrumentation/client/KamonArmeriaOperationNameGenerator.scala @@ -0,0 +1,42 @@ +/* ========================================================================================= + * Copyright © 2013-2020 the kamon project + * + * Licensed under the Apache License, Version 2.0 (the "License") you may not use this file + * except in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + * ========================================================================================= + */ + +package kamon.armeria.instrumentation.client + +import java.util.Locale + +import kamon.armeria.instrumentation.BaseKamonArmeriaOperationNameGenerator +import kamon.instrumentation.http.HttpMessage.Request + +/** + * A GET request to https://github.com/kamon-io/Kamon will generate the following operationName + * github.com.kamon-io.Kamon.get + */ +class KamonArmeriaOperationNameGenerator extends BaseKamonArmeriaOperationNameGenerator { + + override protected def name(request: Request, normalisedPath: String): String = + s"${request.host}.$normalisedPath${request.method.toLowerCase(Locale.ENGLISH)}" + + override protected def key(request: Request): String = + s"${request.host}${request.path}${request.method}" +} + +object KamonArmeriaOperationNameGenerator { + def apply() = new KamonArmeriaOperationNameGenerator() +} + + + + diff --git a/instrumentation/kamon-armeria/src/main/scala/kamon/armeria/instrumentation/interop/JavaInterop.scala b/instrumentation/kamon-armeria/src/main/scala/kamon/armeria/instrumentation/interop/JavaInterop.scala new file mode 100644 index 000000000..fdf2763f8 --- /dev/null +++ b/instrumentation/kamon-armeria/src/main/scala/kamon/armeria/instrumentation/interop/JavaInterop.scala @@ -0,0 +1,16 @@ +package kamon.armeria.instrumentation.interop + +import java.util.function.Consumer + +trait JavaInterop { + + implicit class FunctionInterop[T, R](fn: T => R) { + def asJavaConsumer: Consumer[T] = + new Consumer[T] { + override def accept(t: T): Unit = fn(t) + } + } + +} + + diff --git a/instrumentation/kamon-armeria/src/main/scala/kamon/armeria/instrumentation/server/ArmeriaHttpServerInstrumentation.scala b/instrumentation/kamon-armeria/src/main/scala/kamon/armeria/instrumentation/server/ArmeriaHttpServerInstrumentation.scala new file mode 100644 index 000000000..c1327ec61 --- /dev/null +++ b/instrumentation/kamon-armeria/src/main/scala/kamon/armeria/instrumentation/server/ArmeriaHttpServerInstrumentation.scala @@ -0,0 +1,69 @@ +/* ========================================================================================= + * Copyright © 2013-2020 the kamon project + * + * Licensed under the Apache License, Version 2.0 (the "License") you may not use this file + * except in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + * ========================================================================================= + */ + +package kamon.armeria.instrumentation.server + +import com.linecorp.armeria.server.{ArmeriaHttpServerRequestHandler, ArmeriaHttpServerResponseHandler, ServerPort} +import io.netty.channel.ChannelPipeline +import kanela.agent.api.instrumentation.InstrumentationBuilder +import kanela.agent.api.instrumentation.bridge.FieldBridge +import kanela.agent.libs.net.bytebuddy.asm.Advice + +class ArmeriaHttpServerInstrumentation extends InstrumentationBuilder { + onSubTypesOf("io.netty.channel.Channel") + .mixin(classOf[HasRequestProcessingContextMixin]) + + onType("com.linecorp.armeria.server.HttpServerPipelineConfigurator") + .bridge(classOf[InternalState]) + .advise(method("configureHttp"), classOf[ConfigureMethodAdvisor]) +} + +class ConfigureMethodAdvisor + +object ConfigureMethodAdvisor { + + @Advice.OnMethodExit + def around(@Advice.This configurer: Object, + @Advice.Argument(0) p: ChannelPipeline): Unit = { + + val serverPort = configurer.asInstanceOf[InternalState].getServerPort + val hostName = serverPort.localAddress().getHostName + val port = serverPort.localAddress().getPort + + p.addBefore("HttpServerHandler#0", "armeria-http-server-request-handler", ArmeriaHttpServerRequestHandler(hostName, port)) + p.addLast("armeria-http-server-response-handler", ArmeriaHttpServerResponseHandler()) + } +} + +trait HasRequestProcessingContext { + def setRequestProcessingContext(requestProcessingContext: RequestProcessingContext): Unit + + def getRequestProcessingContext: RequestProcessingContext +} + +class HasRequestProcessingContextMixin extends HasRequestProcessingContext { + @volatile var _requestProcessingContext: RequestProcessingContext = _ + + override def setRequestProcessingContext(requestProcessing: RequestProcessingContext): Unit = + _requestProcessingContext = requestProcessing + + override def getRequestProcessingContext: RequestProcessingContext = + _requestProcessingContext +} + +trait InternalState { + @FieldBridge("port") + def getServerPort: ServerPort +} diff --git a/instrumentation/kamon-armeria/src/main/scala/kamon/armeria/instrumentation/server/KamonArmeriaOperationNameGenerator.scala b/instrumentation/kamon-armeria/src/main/scala/kamon/armeria/instrumentation/server/KamonArmeriaOperationNameGenerator.scala new file mode 100644 index 000000000..02484b714 --- /dev/null +++ b/instrumentation/kamon-armeria/src/main/scala/kamon/armeria/instrumentation/server/KamonArmeriaOperationNameGenerator.scala @@ -0,0 +1,40 @@ +/* ========================================================================================= + * Copyright © 2013-2020 the kamon project + * + * Licensed under the Apache License, Version 2.0 (the "License") you may not use this file + * except in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + * ========================================================================================= + */ + +package kamon.armeria.instrumentation.server + +import java.util.Locale + +import kamon.armeria.instrumentation.BaseKamonArmeriaOperationNameGenerator +import kamon.instrumentation.http.HttpMessage.Request +/** + * A GET request to https://localhost:8080/kamon-io/Kamon will generate the following operationName + * kamon-io.Kamon.get + */ +class KamonArmeriaOperationNameGenerator extends BaseKamonArmeriaOperationNameGenerator { + + override protected def name(request: Request, normalisedPath: String): String = + s"$normalisedPath${request.method.toLowerCase(Locale.ENGLISH)}" + + override protected def key(request: Request): String = + s"${request.method}${request.path}" +} + +object KamonArmeriaOperationNameGenerator { + def apply() = new KamonArmeriaOperationNameGenerator() +} + + + diff --git a/instrumentation/kamon-armeria/src/main/scala/kamon/armeria/instrumentation/server/handlers/ArmeriaHttpServerRequestHandler.scala b/instrumentation/kamon-armeria/src/main/scala/kamon/armeria/instrumentation/server/handlers/ArmeriaHttpServerRequestHandler.scala new file mode 100644 index 000000000..fbf978f75 --- /dev/null +++ b/instrumentation/kamon-armeria/src/main/scala/kamon/armeria/instrumentation/server/handlers/ArmeriaHttpServerRequestHandler.scala @@ -0,0 +1,66 @@ +/* ========================================================================================= + * Copyright © 2013-2020 the kamon project + * + * Licensed under the Apache License, Version 2.0 (the "License") you may not use this file + * except in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + * ========================================================================================= + */ + +package com.linecorp.armeria.server + +import io.netty.channel.{ChannelHandlerContext, ChannelInboundHandlerAdapter} +import kamon.Kamon +import kamon.armeria.instrumentation.server.{HasRequestProcessingContext, RequestProcessingContext} +import kamon.instrumentation.http.{HttpMessage, HttpServerInstrumentation} + +import scala.collection.JavaConverters.iterableAsScalaIterableConverter + +final class ArmeriaHttpServerRequestHandler(serverHost: String, serverPort: Int) extends ChannelInboundHandlerAdapter { + + private val httpServerConfig = Kamon.config().getConfig("kamon.instrumentation.armeria.http-server") + private val serverInstrumentation = HttpServerInstrumentation.from(httpServerConfig, "armeria-http-server", serverHost, serverPort) + + override def channelRead(ctx: ChannelHandlerContext, msg: Any): Unit = { + if (!msg.isInstanceOf[DecodedHttpRequest]) ctx.fireChannelRead(msg) + else { + val processingContext = ctx.channel().asInstanceOf[HasRequestProcessingContext] + val request = msg.asInstanceOf[DecodedHttpRequest] + val serverRequestHandler = serverInstrumentation.createHandler(toRequest(request, serverHost, serverPort)) + + processingContext.setRequestProcessingContext(RequestProcessingContext(serverRequestHandler, Kamon.storeContext(serverRequestHandler.context))) + + ctx.fireChannelRead(msg) + } + } + + private def toRequest(request: DecodedHttpRequest, serverHost: String, serverPort: Int): HttpMessage.Request = new HttpMessage.Request { + + override def url: String = request.uri().toString + + override def path: String = request.path() + + override def method: String = request.method().name() + + override def host: String = serverHost + + override def port: Int = serverPort + + override def read(header: String): Option[String] = + Option(request.headers().get(header)) + + override def readAll(): Map[String, String] = + request.headers().asScala.map(e => e.getKey.toString() -> e.getValue).toMap + } +} + +object ArmeriaHttpServerRequestHandler { + def apply(serverHost: String, serverPort: Int): ArmeriaHttpServerRequestHandler = + new ArmeriaHttpServerRequestHandler(serverHost, serverPort) +} diff --git a/instrumentation/kamon-armeria/src/main/scala/kamon/armeria/instrumentation/server/handlers/ArmeriaHttpServerResponseHandler.scala b/instrumentation/kamon-armeria/src/main/scala/kamon/armeria/instrumentation/server/handlers/ArmeriaHttpServerResponseHandler.scala new file mode 100644 index 000000000..0efd3c898 --- /dev/null +++ b/instrumentation/kamon-armeria/src/main/scala/kamon/armeria/instrumentation/server/handlers/ArmeriaHttpServerResponseHandler.scala @@ -0,0 +1,61 @@ +/* ========================================================================================= + * Copyright © 2013-2020 the kamon project + * + * Licensed under the Apache License, Version 2.0 (the "License") you may not use this file + * except in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + * ========================================================================================= + */ + +package com.linecorp.armeria.server + +import com.linecorp.armeria.common.HttpStatus.NOT_FOUND +import io.netty.channel.{ChannelHandlerContext, ChannelOutboundHandlerAdapter, ChannelPromise} +import io.netty.handler.codec.http.HttpResponse +import kamon.armeria.instrumentation.server.HasRequestProcessingContext +import kamon.instrumentation.http.HttpMessage + +final class ArmeriaHttpServerResponseHandler extends ChannelOutboundHandlerAdapter { + + override def write(ctx: ChannelHandlerContext, msg: Any, promise: ChannelPromise): Unit = { + if (!msg.isInstanceOf[HttpResponse]) ctx.write(msg, promise) + else { + val response = msg.asInstanceOf[HttpResponse] + val processingContext = ctx.channel().asInstanceOf[HasRequestProcessingContext].getRequestProcessingContext + + processingContext.requestHandler.buildResponse(toResponse(response), processingContext.scope.context) + + if (response.status().code() == NOT_FOUND.code()) { + processingContext.requestHandler.span.name("unhandled") + } + + try ctx.write(msg, promise) finally { + processingContext.requestHandler.responseSent() + processingContext.scope.close() + } + } + } + + private def toResponse(response: HttpResponse): HttpMessage.ResponseBuilder[HttpResponse] = new HttpMessage.ResponseBuilder[HttpResponse] { + override def build(): HttpResponse = + response + + override def statusCode: Int = + response.status().code() + + override def write(header: String, value: String): Unit = + response.headers().add(header, value) + } +} + +object ArmeriaHttpServerResponseHandler { + def apply(): ArmeriaHttpServerResponseHandler = + new ArmeriaHttpServerResponseHandler() +} + diff --git a/instrumentation/kamon-armeria/src/main/scala/kamon/armeria/instrumentation/server/package.scala b/instrumentation/kamon-armeria/src/main/scala/kamon/armeria/instrumentation/server/package.scala new file mode 100644 index 000000000..44812ab8a --- /dev/null +++ b/instrumentation/kamon-armeria/src/main/scala/kamon/armeria/instrumentation/server/package.scala @@ -0,0 +1,25 @@ +/* ========================================================================================= + * Copyright © 2013-2020 the kamon project + * + * Licensed under the Apache License, Version 2.0 (the "License") you may not use this file + * except in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + * ========================================================================================= + */ + +package kamon.armeria.instrumentation + +import kamon.context.Storage +import kamon.instrumentation.http.HttpServerInstrumentation.RequestHandler + +package object server { + + final case class RequestProcessingContext(requestHandler: RequestHandler, scope: Storage.Scope) + +} diff --git a/instrumentation/kamon-armeria/src/test/resources/application.conf b/instrumentation/kamon-armeria/src/test/resources/application.conf new file mode 100644 index 000000000..d4a2c0fb3 --- /dev/null +++ b/instrumentation/kamon-armeria/src/test/resources/application.conf @@ -0,0 +1,261 @@ +# =================================== # +# kamon-armeria reference configuration # +# =================================== # + + +kamon.instrumentation.armeria { + + # Settings to control the HTTP Server instrumentation. + # + # IMPORTANT: Besides the "initial-operation-name" and "unhandled-operation-name" settings, the entire configuration of + # the HTTP Server 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-server.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. + # + http-server { + + # + # Configuration for HTTP context propagation. + # + propagation { + + # Enables or disables HTTP context propagation on this HTTP server 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" + + + } + + + # + # Configuration for HTTP server metrics collection. + # + metrics { + + # Enables collection of HTTP server metrics. When enabled the following metrics will be collected, assuming + # that the instrumentation is fully compliant: + # + # - http.server.requets + # - http.server.request.active + # - http.server.request.size + # - http.server.response.size + # - http.server.connection.lifetime + # - http.server.connection.usage + # - http.server.connection.open + # + # All metrics have at least three tags: component, interface and port. Additionally, the http.server.requests + # metric will also have a status_code tag with the status code group (1xx, 2xx and so on). + # + #enabled = yes + } + + + # + # Configuration for HTTP request tracing. + # + tracing { + + # Enables HTTP request tracing. When enabled the instrumentation will create Spans for incoming requests + # and finish them when the response is sent back to the clients. + #enabled = yes + + # Select a context tag that provides a preferred trace identifier. The preferred trace identifier will be used + # only if all these conditions are met: + # - the context tag is present. + # - there is no parent Span on the incoming context (i.e. this is the first service on the trace). + # - the identifier is valid in accordance to the identity provider. + #preferred-trace-id-tag = "none" + + # 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 Server Span crekamon.trace.sampler = alwaysated by the instrumentation, the following configuration + # should be added: + # + # from-context { + # customer_type = span + # } + # + from-context { + + } + } + + # Controls writing trace and span identifiers to HTTP response headers sent by the instrumented servers. The + # configuration can be set to either "none" to disable writing the identifiers on the response headers or to + # the header name to be used when writing the identifiers. + response-headers { + + # HTTP response header name for the trace identifier, or "none" to disable it. + #trace-id = "trace-id" + + # HTTP response header name for the server span identifier, or "none" to disable it. + #span-id = none + } + + # Custom mappings between routes and operation names. + operations { + + # The default operation name to be used when creating Spans to handle the HTTP server requests. In most + # cases it is not possible to define an operation name right at the moment of starting the HTTP server Span + # and in those cases, this operation name will be initially assigned to the Span. Instrumentation authors + # should do their best effort to provide a suitable operation name or make use of the "mappings" facilities. + default = "http.server.request" + + # The operation name to be assigned when an application cannot find any route/endpoint/controller to handle + # a given request. Depending on the instrumented framework, it might be possible to apply this operation + # name automatically or not, check the frameworks' instrumentation docs for more details. + unhandled = "unhandled" + + # FQCN for a HttpOperationNameGenerator implementation, or ony of the following shorthand forms: + # - default: Uses the set default operation name + # - method: Uses the request HTTP method as the operation name. + # + name-generator = "kamon.armeria.instrumentation.server.KamonArmeriaOperationNameGenerator" + + # Provides custom mappings from HTTP paths into operation names. Meant to be used in cases where the bytecode + # instrumentation is not able to provide a sensible operation name that is free of high cardinality values. + # For example, with the following configuration: + # mappings { + # "/organization/*/user/*/profile" = "/organization/:orgID/user/:userID/profile" + # "/events/*/rsvps" = "EventRSVPs" + # } + # + # Requests to "/organization/3651/user/39652/profile" and "/organization/22234/user/54543/profile" will have + # the same operation name "/organization/:orgID/user/:userID/profile". + # + # Similarly, requests to "/events/aaa-bb-ccc/rsvps" and "/events/1234/rsvps" will have the same operation + # name "EventRSVPs". + # + # The patterns are expressed as globs and the operation names are free form. + # + mappings { + "/dummy-resources/*/other-resources/*" = "dummy-resources/{}/other-resources/{}" + } + } + } + } + + # 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. + # + http-client { + # + # Configuration for HTTP context propagation. + # + propagation { + + # Enables or disables HTTP context propagation on this HTTP server 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 Server 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 bellow 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 = "kamon.armeria.instrumentation.client.KamonArmeriaOperationNameGenerator" + } + } + } + +} + +kanela { + modules { + armeria-http-server { + name = "Armeria http server Instrumentation" + stoppable = true + instrumentations = [ + "kamon.armeria.instrumentation.server.ArmeriaHttpServerInstrumentation" + ] + within = [ "io.netty..*", "com.linecorp.armeria..*"] + } + armeria-http-client { + name = "Armeria http client Instrumentation" + stoppable = true + instrumentations = [ + "kamon.armeria.instrumentation.client.ArmeriaHttpClientInstrumentation" + ] + within = [ "com.linecorp.armeria.client..*"] + } + } +} diff --git a/instrumentation/kamon-armeria/src/test/resources/logback.xml b/instrumentation/kamon-armeria/src/test/resources/logback.xml new file mode 100644 index 000000000..a923265e7 --- /dev/null +++ b/instrumentation/kamon-armeria/src/test/resources/logback.xml @@ -0,0 +1,13 @@ + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + diff --git a/instrumentation/kamon-armeria/src/test/scala/kamon/armeria/instrumentation/client/ArmeriaHttpClientTracingSpec.scala b/instrumentation/kamon-armeria/src/test/scala/kamon/armeria/instrumentation/client/ArmeriaHttpClientTracingSpec.scala new file mode 100644 index 000000000..440375245 --- /dev/null +++ b/instrumentation/kamon-armeria/src/test/scala/kamon/armeria/instrumentation/client/ArmeriaHttpClientTracingSpec.scala @@ -0,0 +1,174 @@ +package kamon.armeria.instrumentation.client + +import com.linecorp.armeria.client.{Clients, WebClient} +import com.linecorp.armeria.common.{HttpMethod, HttpRequest, RequestHeaders} +import kamon.Kamon +import kamon.context.Context +import kamon.tag.Lookups.{plain, plainBoolean, plainLong} +import kamon.testkit.TestSpanReporter +import kamon.trace.Span +import kamon.trace.SpanPropagation.B3 +import org.scalatest.concurrent.Eventually +import org.scalatest.time.SpanSugar.convertIntToGrainOfTime +import org.scalatest.{BeforeAndAfterAll, Matchers, OptionValues, WordSpec} +import utils.ArmeriaServerSupport.startArmeriaServer +import utils.TestSupport.getResponseHeaders + +class ArmeriaHttpClientTracingSpec extends WordSpec + with Matchers + with BeforeAndAfterAll + with Eventually + with TestSpanReporter + with OptionValues { + + val customTag = "requestId" + val customHeaderName = "X-Request-Id" + + val interface = "127.0.0.1" + val httpPort = 8081 + + val httpServer = startArmeriaServer(httpPort) + + "The Armeria http client tracing instrumentation" should { + "propagate the current context and generate a span around an async request" in { + val path = "/dummy" + val url = s"http://$interface:$httpPort" + + val okSpan = Kamon.spanBuilder("ok-async-operation-span").start() + val client = Clients.builder(url).build(classOf[WebClient]) + val request = HttpRequest.of(RequestHeaders.of(HttpMethod.GET, path)) + + val response = Kamon.runWithContext(Context.of(Span.Key, okSpan)) { + client.execute(request).aggregate().get() + } + + val span = eventually(timeout(3 seconds)) { + val span = testSpanReporter().nextSpan().value + span.operationName shouldBe s"$interface.dummy.get" + span.kind shouldBe Span.Kind.Client + span.metricTags.get(plain("component")) shouldBe "armeria-http-client" + span.metricTags.get(plain("http.method")) shouldBe "GET" + span.metricTags.get(plainLong("http.status_code")) shouldBe 200 + span.metricTags.get(plainBoolean("error")) shouldBe false + span.tags.get(plain("http.url")) shouldBe s"$url$path" + okSpan.id shouldBe span.parentId + + testSpanReporter().nextSpan() shouldBe None + + span + } + + val responseHeaders = getResponseHeaders(response) + + responseHeaders.get(B3.Headers.TraceIdentifier.toLowerCase).value should be(span.trace.id.string) + responseHeaders.get(B3.Headers.SpanIdentifier.toLowerCase).value should be(span.id.string) + responseHeaders.get(B3.Headers.ParentSpanIdentifier.toLowerCase).value should be(span.parentId.string) + responseHeaders.get(B3.Headers.Sampled.toLowerCase).value should be("1") + + + } + + "propagate context tags" in { + val path = "/dummy" + val url = s"http://$interface:$httpPort" + + val okSpan = Kamon.spanBuilder("ok-span-with-extra-tags").start() + val client = Clients.builder(url).build(classOf[WebClient]) + val request = HttpRequest.of(RequestHeaders.of(HttpMethod.GET, path)) + + val response = Kamon.runWithContext(Context.of(Span.Key, okSpan).withTag(customTag, "1234")) { + client.execute(request).aggregate().get() + } + + val span: Span.Finished = eventually(timeout(3 seconds)) { + val span = testSpanReporter().nextSpan().value + + span.operationName shouldBe s"$interface.dummy.get" + span.kind shouldBe Span.Kind.Client + span.metricTags.get(plain("component")) shouldBe "armeria-http-client" + span.metricTags.get(plain("http.method")) shouldBe "GET" + span.metricTags.get(plainLong("http.status_code")) shouldBe 200 + span.metricTags.get(plainBoolean("error")) shouldBe false + span.tags.get(plain("http.url")) shouldBe s"$url$path" + span.tags.get(plain(customTag)) shouldBe "1234" + + okSpan.id == span.parentId + + testSpanReporter().nextSpan() shouldBe None + + span + } + + val responseHeaders = getResponseHeaders(response) + + responseHeaders.get(B3.Headers.TraceIdentifier.toLowerCase).value should be(span.trace.id.string) + responseHeaders.get(B3.Headers.SpanIdentifier.toLowerCase).value should be(span.id.string) + responseHeaders.get(B3.Headers.ParentSpanIdentifier.toLowerCase).value should be(span.parentId.string) + responseHeaders.get(B3.Headers.Sampled.toLowerCase).value should be("1") + responseHeaders.get(customHeaderName.toLowerCase).value should be("1234") + + } + + + "mark span as failed when server response with 5xx on async execution" in { + val path = "/dummy-error" + val url = s"http://$interface:$httpPort" + + val okSpan = Kamon.spanBuilder("ok-async-operation-span").start() + val client = Clients.builder(url).build(classOf[WebClient]) + val request = HttpRequest.of(RequestHeaders.of(HttpMethod.GET, path)) + + val response = Kamon.runWithContext(Context.of(Span.Key, okSpan)) { + val response = client.execute(request) + response.aggregate().get() + } + + val span: Span.Finished = eventually(timeout(3 seconds)) { + val span = testSpanReporter().nextSpan().value + + span.operationName shouldBe s"$interface.dummy-error.get" + span.kind shouldBe Span.Kind.Client + span.metricTags.get(plain("component")) shouldBe "armeria-http-client" + 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.tags.get(plain("http.url")) shouldBe s"$url$path" + + okSpan.id == span.parentId + + testSpanReporter().nextSpan() shouldBe None + + span + } + + val responseHeaders = getResponseHeaders(response) + + responseHeaders.get(B3.Headers.TraceIdentifier.toLowerCase).value should be(span.trace.id.string) + responseHeaders.get(B3.Headers.SpanIdentifier.toLowerCase).value should be(span.id.string) + responseHeaders.get(B3.Headers.ParentSpanIdentifier.toLowerCase).value should be(span.parentId.string) + responseHeaders.get(B3.Headers.Sampled.toLowerCase).value should be("1") + } + } + + + override protected def beforeAll(): Unit = { + applyConfig( + s""" + |kamon { + | propagation.http.default.tags.mappings { + | $customTag = $customHeaderName + | } + | instrumentation.http-client.default.tracing.tags.from-context { + | $customTag = span + | } + |} + |""".stripMargin) + enableFastSpanFlushing() + sampleAlways() + } + + + override protected def afterAll(): Unit = { + httpServer.close() + } +} diff --git a/instrumentation/kamon-armeria/src/test/scala/kamon/armeria/instrumentation/server/ArmeriaHttpServerTracingSpec.scala b/instrumentation/kamon-armeria/src/test/scala/kamon/armeria/instrumentation/server/ArmeriaHttpServerTracingSpec.scala new file mode 100644 index 000000000..aacb54ce9 --- /dev/null +++ b/instrumentation/kamon-armeria/src/test/scala/kamon/armeria/instrumentation/server/ArmeriaHttpServerTracingSpec.scala @@ -0,0 +1,126 @@ +/* ========================================================================================= + * Copyright © 2013-2020 the kamon project + * + * Licensed under the Apache License, Version 2.0 (the "License") you may not use this file + * except in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + * ========================================================================================= + */ + +package kamon.armeria.instrumentation.server + +import kamon.tag.Lookups.{plain, plainBoolean, plainLong} +import kamon.testkit._ +import okhttp3.{OkHttpClient, Request} +import org.scalatest.OptionValues.convertOptionToValuable +import org.scalatest.concurrent.Eventually +import org.scalatest.{BeforeAndAfterAll, Matchers, WordSpec} +import utils.ArmeriaServerSupport.startArmeriaServer +import utils.TestEndpoints.{dummyErrorPath, dummyMultipleResourcesPath, dummyNotFoundPath, dummyPath} + +import scala.concurrent.duration._ + +class ArmeriaHttpServerTracingSpec extends WordSpec + with Matchers + with BeforeAndAfterAll + with Eventually + with TestSpanReporter { + + private val okHttp = new OkHttpClient.Builder().build() + + val interface = "127.0.0.1" + val httpPort = 8081 + val httpsPort = 8082 + + private val httpServer = startArmeriaServer(httpPort) + + testSuite("http", interface, httpPort) + + private def testSuite(protocol: String, interface: String, port: Int): Unit = { + + s"The Armeria $protocol server" should { + + "create a server Span when receiving requests" in { + val target = s"$protocol://$interface:$port/$dummyPath" + val expected = "dummy.get" + okHttp.newCall(new Request.Builder().url(target).get().build()).execute().close() + + eventually(timeout(10 seconds)) { + val span = testSpanReporter().nextSpan().value + span.operationName shouldBe expected + span.tags.get(plain("http.url")) shouldBe target + span.metricTags.get(plain("component")) shouldBe "armeria-http-server" + span.metricTags.get(plain("http.method")) shouldBe "GET" + span.metricTags.get(plainLong("http.status_code")) shouldBe 200L + } + } + + "set operation name with unhandled" when { + "request path doesn't exists" in { + val target = s"$protocol://$interface:$port/$dummyNotFoundPath" + val expected = "unhandled" + okHttp.newCall(new Request.Builder().url(target).get().build()).execute().close() + + eventually(timeout(10 seconds)) { + val span = testSpanReporter().nextSpan().value + span.operationName shouldBe expected + span.tags.get(plain("http.url")) shouldBe target + span.metricTags.get(plain("component")) shouldBe "armeria-http-server" + span.metricTags.get(plain("http.method")) shouldBe "GET" + span.metricTags.get(plainBoolean("error")) shouldBe false + span.metricTags.get(plainLong("http.status_code")) shouldBe 404 + } + } + } + + "not include path variables names" in { + val target = s"$protocol://$interface:$port/$dummyMultipleResourcesPath" + val expected = "dummy-resources/{}/other-resources/{}" + okHttp.newCall(new Request.Builder().url(target).get().build()).execute().close() + + eventually(timeout(10 seconds)) { + val span = testSpanReporter().nextSpan().value + span.operationName shouldBe expected + } + } + + "not fail when request url contains special regexp chars" in { + val target = s"$protocol://$interface:$port/$dummyMultipleResourcesPath**" + val expected = "dummy-resources/{}/other-resources/{}" + val response = okHttp.newCall(new Request.Builder().url(target).build()).execute() + + eventually(timeout(10 seconds)) { + val span = testSpanReporter().nextSpan().value + span.operationName shouldBe expected + response.code() shouldBe 200 + } + response.close() + } + + "mark spans as failed when request fails" in { + val target = s"$protocol://$interface:$port/$dummyErrorPath" + val expected = s"$dummyErrorPath.get" + okHttp.newCall(new Request.Builder().url(target).build()).execute().close() + + eventually(timeout(10 seconds)) { + val span = testSpanReporter().nextSpan().value + span.operationName shouldBe expected + span.tags.get(plain("http.url")) shouldBe target + span.metricTags.get(plain("component")) shouldBe "armeria-http-server" + span.metricTags.get(plain("http.method")) shouldBe "GET" + span.metricTags.get(plainBoolean("error")) shouldBe true + span.metricTags.get(plainLong("http.status_code")) shouldBe 500 + } + } + } + } + + override protected def afterAll(): Unit = + httpServer.close() +} diff --git a/instrumentation/kamon-armeria/src/test/scala/utils/ArmeriaServerSupport.scala b/instrumentation/kamon-armeria/src/test/scala/utils/ArmeriaServerSupport.scala new file mode 100644 index 000000000..b536001f7 --- /dev/null +++ b/instrumentation/kamon-armeria/src/test/scala/utils/ArmeriaServerSupport.scala @@ -0,0 +1,40 @@ +/* ========================================================================================= + * Copyright © 2013-2020 the kamon project + * + * Licensed under the Apache License, Version 2.0 (the "License") you may not use this file + * except in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + * ========================================================================================= + */ + +package utils + +import java.net.InetSocketAddress + +import com.linecorp.armeria.server.Server +import com.linecorp.armeria.server.healthcheck.HealthCheckService + +object ArmeriaServerSupport { + + def startArmeriaServer(port: Int, https: Boolean = false): Server = { + val server = Server + .builder() + .service("/health-check", HealthCheckService.of()) + .annotatedService().build(TestRoutesSupport()) + .http(InetSocketAddress.createUnresolved("localhost", port)) + .build() + + server + .start() + .join() + + server + } + +} diff --git a/instrumentation/kamon-armeria/src/test/scala/utils/TestRoutesSupport.scala b/instrumentation/kamon-armeria/src/test/scala/utils/TestRoutesSupport.scala new file mode 100644 index 000000000..6962c7c1d --- /dev/null +++ b/instrumentation/kamon-armeria/src/test/scala/utils/TestRoutesSupport.scala @@ -0,0 +1,50 @@ +/* ========================================================================================= + * Copyright © 2013-2020 the kamon project + * + * Licensed under the Apache License, Version 2.0 (the "License") you may not use this file + * except in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + * ========================================================================================= + */ + +package utils + +import com.linecorp.armeria.common.{HttpRequest, HttpResponse, HttpStatus, ResponseHeaders} +import com.linecorp.armeria.server.annotation.{Get, Param} + +final class TestRoutesSupport { + @Get("/dummy") + def getDummy(req: HttpRequest): HttpResponse = { + val responseHeaders = ResponseHeaders.builder(HttpStatus.OK).add(req.headers()).build() + HttpResponse.of(responseHeaders) + } + + @Get("/dummy-resources/{resource}/other-resources/{other}") + def getResource(@Param("resource") resource: String, @Param("other") other: String): HttpResponse = { + println(s"Received a request to retrieve resource $resource and $other") + HttpResponse.of(HttpStatus.OK) + } + + @Get("/dummy-error") + def getDummyError(req: HttpRequest): HttpResponse = { + val responseHeaders = ResponseHeaders.builder(HttpStatus.INTERNAL_SERVER_ERROR).add(req.headers()).build() + HttpResponse.of(responseHeaders) + } +} + +object TestRoutesSupport { + def apply(): TestRoutesSupport = new TestRoutesSupport() +} + +object TestEndpoints { + val dummyPath = "dummy" + val dummyErrorPath = "dummy-error" + val dummyMultipleResourcesPath = "dummy-resources/a/other-resources/b" + val dummyNotFoundPath = "dummy-not-found" +} diff --git a/instrumentation/kamon-armeria/src/test/scala/utils/TestSupport.scala b/instrumentation/kamon-armeria/src/test/scala/utils/TestSupport.scala new file mode 100644 index 000000000..3525b8239 --- /dev/null +++ b/instrumentation/kamon-armeria/src/test/scala/utils/TestSupport.scala @@ -0,0 +1,33 @@ +/* ========================================================================================= + * Copyright © 2013-2020 the kamon project + * + * Licensed under the Apache License, Version 2.0 (the "License") you may not use this file + * except in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + * ========================================================================================= + */ + +package utils + +import com.linecorp.armeria.common.AggregatedHttpResponse + +import scala.collection.JavaConverters + + +object TestSupport { + def getResponseHeaders(response: AggregatedHttpResponse): Map[String, String] = + JavaConverters + .asScalaIteratorConverter(response.headers().iterator()) + .asScala + .map(entry => (entry.getKey.toString, entry.getValue)) + .toMap +} + + +