├── .gitignore ├── .gitlab-ci.yml ├── .gitmodules ├── .gitsecret ├── keys │ ├── crls.d │ │ └── DIR.txt │ ├── pubring.kbx │ ├── pubring.kbx~ │ └── trustdb.gpg └── paths │ └── mapping.cfg ├── .jvmopts ├── .scalafmt.conf ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE.md ├── README.md ├── amqp └── src │ ├── main │ └── scala │ │ └── com │ │ └── colisweb │ │ └── application │ │ └── context │ │ └── amqp │ │ ├── AmqpConsumerWithCorrelationIdHelper.scala │ │ └── package.scala │ └── test │ └── scala │ └── com │ └── colisweb │ └── application │ └── context │ └── amqp │ └── AmqpMessageSpec.scala ├── build.sbt ├── context └── src │ ├── main │ └── scala │ │ └── com │ │ └── colisweb │ │ └── tracing │ │ └── context │ │ ├── LoggingTracingContext.scala │ │ ├── NoOpTracingContext.scala │ │ ├── OpenTracingContext.scala │ │ ├── datadog │ │ └── DDTracingContext.scala │ │ └── logging │ │ └── TracingLogger.scala │ └── test │ ├── resources │ └── logback.xml │ └── scala │ └── com │ └── colisweb │ └── tracing │ └── context │ ├── LogCorrelationSpec.scala │ ├── LoggingTracingContextSpec.scala │ └── TestUtils.scala ├── core └── src │ └── main │ └── scala │ └── com │ └── colisweb │ └── tracing │ └── core │ ├── PureLogger.scala │ ├── TracingContext.scala │ ├── TracingContextBuilder.scala │ ├── implicits │ ├── Syntax.scala │ └── package.scala │ └── package.scala ├── http ├── client │ └── src │ │ └── main │ │ └── scala │ │ └── com │ │ └── colisweb │ │ └── tracing │ │ └── http │ │ └── client │ │ ├── RequestWithCorrelationIdHelper.scala │ │ └── package.scala ├── server │ └── src │ │ ├── main │ │ └── scala │ │ │ └── com │ │ │ └── colisweb │ │ │ └── tracing │ │ │ └── http │ │ │ └── server │ │ │ ├── TracedHttpRoutes.scala │ │ │ ├── TracedRequest.scala │ │ │ ├── TracedRoutes.scala │ │ │ └── package.scala │ │ └── test │ │ └── scala │ │ └── com │ │ └── colisweb │ │ └── tracing │ │ └── http │ │ └── server │ │ ├── TapirSpec.scala │ │ └── TracedEndpointSpec.scala └── test │ └── src │ └── test │ └── scala │ └── com │ └── colisweb │ └── tracing │ └── http │ └── test │ ├── CorrelationIdEndToEndTest.scala │ ├── TestServer.scala │ ├── WrappedCorrelationId.scala │ └── package.scala ├── logo.png ├── project ├── CompileFlags.scala ├── CompileTimeDependencies.scala ├── ReleaseSettings.scala ├── build.properties └── plugins.sbt ├── publish.sbt └── secrets-env.sh.secret /.gitignore: -------------------------------------------------------------------------------- 1 | .bloop 2 | .metals 3 | .idea 4 | .vscode 5 | target 6 | *.class 7 | .gitsecret/keys/random_seed 8 | !*.secret 9 | secrets-env.sh 10 | .bsp -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | include: 2 | - 'https://colisweb-open-source.gitlab.io/ci-common/v13.4.3/templates/scala.yml' 3 | 4 | .opentracing-test: 5 | extends: .sbt-test 6 | tags: [] 7 | 8 | test scala-opentracing-amqp: 9 | extends: .opentracing-test 10 | 11 | test scala-opentracing-core: 12 | extends: .opentracing-test 13 | 14 | test scala-opentracing-context: 15 | extends: .opentracing-test 16 | 17 | test scala-opentracing-http4s-server-tapir: 18 | extends: .opentracing-test 19 | 20 | test scala-opentracing-http4s-client-blaze: 21 | extends: .opentracing-test 22 | 23 | test scala-opentracing-http4s-test: 24 | extends: .opentracing-test 25 | 26 | version: 27 | extends: .version 28 | tags: [] 29 | 30 | publish on sonatype: 31 | extends: .sonatype-publish 32 | tags: [] 33 | 34 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "scala-common"] 2 | path = scala-common 3 | url = ../scala-common.git 4 | branch = v1.6.2 5 | -------------------------------------------------------------------------------- /.gitsecret/keys/crls.d/DIR.txt: -------------------------------------------------------------------------------- 1 | v:1: 2 | -------------------------------------------------------------------------------- /.gitsecret/keys/pubring.kbx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Colisweb/scala-opentracing/69fc2c836bd746ad31c65bd14348c58c4f1f9bab/.gitsecret/keys/pubring.kbx -------------------------------------------------------------------------------- /.gitsecret/keys/pubring.kbx~: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Colisweb/scala-opentracing/69fc2c836bd746ad31c65bd14348c58c4f1f9bab/.gitsecret/keys/pubring.kbx~ -------------------------------------------------------------------------------- /.gitsecret/keys/trustdb.gpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Colisweb/scala-opentracing/69fc2c836bd746ad31c65bd14348c58c4f1f9bab/.gitsecret/keys/trustdb.gpg -------------------------------------------------------------------------------- /.gitsecret/paths/mapping.cfg: -------------------------------------------------------------------------------- 1 | secrets-env.sh:1f1c87ed2f9d20cc6a85c02571a95f6648773b4698869d4af1e3847379d993bd 2 | -------------------------------------------------------------------------------- /.jvmopts: -------------------------------------------------------------------------------- 1 | -XX:MaxMetaspaceSize=512M 2 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = "3.0.0" 2 | style = defaultWithAlign 3 | maxColumn = 140 4 | align = more 5 | optIn.breakChainOnFirstMethodDot = false -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Scala Opentracing 2 | All notable changes to this project will be documented in this file. 3 | This project adheres to [Semantic Versioning](https://semver.org/). 4 | 5 | More infos about this file : https://keepachangelog.com/ 6 | 7 | ## [Unreleased] - no_due_date 8 | 9 | ## [v0.1.0] 2019.09.12 10 | 11 | ### Core 12 | 13 | - Breaking : `com.colisweb.tracing.core.TracingContext.TracingContextBuilder` has been moved to `com.colisweb.tracing.TracingContextBuilder` 14 | - `LoggingTracingContext` now prints tags to the console 15 | - All tracing context companion objects now include a `get[...]TracingContextBuilder[F]` method that returns a `F[TracingContextBuilder[F]]` . The `TracingContext[F]` that this `TracingContextBuilder[F]` will build will have no parent span. This has been done for consitency with regard to the `DDTracingContext` which requires some effectful registration to be ran before the tracer can work properly. 16 | - Code has been reorganised so all implicits can be imported with `com.colisweb.tracing.implicits._` 17 | - There is now a `Tags` type alias for `Map[String, String]` 18 | - Log correlation has been reorgnaised to support more than Datadog in the future 19 | 20 | ### Tapir integration 21 | - One can now pass a `Http4sServerOptions` class to the `toTracedRoute` and `toTracedRouteRecoverErrors` -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | Contributor Covenant Code of Conduct 2 | 3 | Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | 18 | * Using welcoming and inclusive language 19 | * Being respectful of differing viewpoints and experiences 20 | * Gracefully accepting constructive criticism 21 | * Focusing on what is best for the community 22 | * Showing empathy towards other community members 23 | 24 | 25 | Examples of unacceptable behavior by participants include: 26 | 27 | 28 | * The use of sexualized language or imagery and unwelcome sexual attention or 29 | advances 30 | * Trolling, insulting/derogatory comments, and personal or political attacks 31 | * Public or private harassment 32 | * Publishing others’ private information, such as a physical or electronic 33 | address, without explicit permission 34 | * Other conduct which could reasonably be considered inappropriate in a 35 | professional setting 36 | 37 | 38 | Our Responsibilities 39 | 40 | Project maintainers are responsible for clarifying the standards of acceptable 41 | behavior and are expected to take appropriate and fair corrective action in 42 | response to any instances of unacceptable behavior. 43 | 44 | Project maintainers have the right and responsibility to remove, edit, or 45 | reject comments, commits, code, wiki edits, issues, and other contributions 46 | that are not aligned to this Code of Conduct, or to ban temporarily or 47 | permanently any contributor for other behaviors that they deem inappropriate, 48 | threatening, offensive, or harmful. 49 | 50 | Scope 51 | 52 | This Code of Conduct applies within all project spaces, and it also applies when 53 | an individual is representing the project or its community in public spaces. 54 | Examples of representing a project or community include using an official 55 | project e-mail address, posting via an official social media account, or acting 56 | as an appointed representative at an online or offline event. Representation of 57 | a project may be further defined and clarified by project maintainers. 58 | 59 | Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported by contacting the project team at michel.daviot@colisweb.com. All 63 | complaints will be reviewed and investigated and will result in a response that 64 | is deemed necessary and appropriate to the circumstances. The project team is 65 | obligated to maintain confidentiality with regard to the reporter of an incident. 66 | Further details of specific enforcement policies may be posted separately. 67 | 68 | Project maintainers who do not follow or enforce the Code of Conduct in good 69 | faith may face temporary or permanent repercussions as determined by other 70 | members of the project’s leadership. 71 | 72 | Attribution 73 | 74 | This Code of Conduct is adapted from the Contributor Covenant, version 1.4, 75 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 76 | 77 | For answers to common questions about this code of conduct, see 78 | https://www.contributor-covenant.org/faq -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Colisweb 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Scala Opentracing](./logo.png) 2 | 3 | # Get Scala Opentracing 4 | 5 | ## Download from maven 6 | [Scala-opentracing](https://repo1.maven.org/maven2/com/colisweb/) 7 | 8 | # Scala Opentracing 9 | 10 | Scala Opentracing is a Scala wrapper around the Opentracing library for Java, along with utilities to monitor Http4s applications. It was originally 11 | developed to address our needs for Datadog APM instrumentation at Colisweb. 12 | 13 | It is being used in production at Colisweb. 14 | 15 | ## Goals 16 | 17 | - **Provide Datadog instrumentation for our Scala applications** : this tracing library should work with any Opentracing compatible tracer, 18 | like Jaeger, but it has only been tested with Datadog so far. 19 | - **Stay out of the way** : we don't want the tracing system to get in the way of our business logic, so 20 | - we try to reduce to a minimum the amount of boilerplate required to use this library. Furthermore, Spans 21 | are automatically closed by a `Resource` from Cats effect for safety and convenience. 22 | - **Be idiomatic** : we try to follow the principles of functional programming in Scala, and wrap all side-effects of the Java libraries into algebraic effects. 23 | 24 | ## Installation 25 | 26 | And add the core library to your dependencies : 27 | 28 | ```scala 29 | libraryDependencies += "com.colisweb" %% "scala-opentracing-core" % "2.5.0" 30 | ``` 31 | 32 | ## Usage 33 | 34 | ## Creating a TracingContext explicitly 35 | 36 | A `TracingContext[F[_]]` represents some unit of work, which can spawn child units of work. 37 | 38 | This library provides multiples instances of `TracingContext`: 39 | * `OpenTracingContext` 40 | * `DDTracingContext` 41 | * `LoggingTracingContext` 42 | * `NoOpTracingContext` 43 | 44 | All of these contexts can be added to your project by adding the following import: 45 | ```scala 46 | libraryDependencies += "com.colisweb" %% "scala-opentracing-context" % "2.5.0" 47 | ``` 48 | 49 | If you use Datadog, create a `DDTracingContext`, otherwise use `OpenTracingContext`. Both will rely on some `Tracer`, provided by whatever tracing 50 | library you use (Jaeger, Datadog ...). 51 | 52 | In the following examples, we'll use `IO` from Cats effect as our `F` but you can use any monad that satisfies the `Sync` Typeclass, like `Task` from Monix. 53 | 54 | ```scala 55 | import io.opentracing._ 56 | import com.colisweb.tracing.context._ 57 | 58 | val tracer: Tracer = ??? 59 | val tracingContextBuilder: IO[TracingContextBuilder[IO]] = OpenTracingContext.builder[IO, Tracer, Span](tracer) 60 | ``` 61 | 62 | All contexts possess a `builder` method which return a `F[TracingContextBuilder[F]]` 63 | Arguments that needs to be passed may differ based on the implementation. 64 | However, `TracingContextBuilder[F]` possess a single method which take the `operation name` and some `tags`. 65 | 66 | ### Creating a TracingContext for Datadog 67 | 68 | Datadog requires that you create a tracer and then register it as the "Global tracer" for your application. 69 | 70 | For convenience and purity, this library creates and register the tracer itself. 71 | 72 | 73 | ```scala 74 | import com.colisweb.tracing.context.datadog._ 75 | 76 | val tracingContextBuilder: IO[TracingContextBuilder[IO]] = DDTracingContext.builder[IO]("your-service-name") 77 | ``` 78 | 79 | Don't evaluate this effect multiple times, as it will recreate a new tracer every time. Instead, use it in the *main* of your application, and propagate 80 | the `TracingContextBuilder` down where you need it. 81 | 82 | ### Logging all your traces for development 83 | 84 | For development or fast prototyping purposes, you can also use `LoggingTracingContext`, that will log all your operations to the standard 85 | output, along with the time every operation took in milliseconds. 86 | 87 | ```scala 88 | val tracingContextBuilder: IO[TracingContextBuilder[IO]] = LoggingTracingContext.builder[IO]() 89 | ``` 90 | 91 | To see the logs, make sur the `trace` level is enabled in SLF4J. 92 | 93 | Once you have a `TracingContextBuilder[F[_]]`, you can use to wrap your computations. 94 | 95 | ```scala 96 | // You can pass tags as a Tags 97 | val result: IO[Int] = tracingContextBuilder("Heavy mathematics", Map.empty) use { _ => 98 | IO { 42 - 5 } 99 | } 100 | ``` 101 | 102 | Notice how the context is wrapped inside a `Resource[IO, TracingContext]`. This means the span will 103 | automatically closed at the end of the computation. You can use the `TracingContext` you get from the 104 | `Resource` to create a child computation : 105 | 106 | ```scala 107 | val result: IO[Int] = tracingContextBuilder("Parent context", Map.empty) use { parentCtx => 108 | // Some work here ... 109 | parentCtx.span("Child context") use { _ => 110 | IO { /* And some work there */ } 111 | } *> parentCtx.span("Sibling context") use { _ => 112 | IO { 20 + 20 } 113 | } 114 | } 115 | 116 | // Result value will 20. The Spans graph will look like this : 117 | // <------------------ Parent context ------------------> 118 | // <---- Child context ----> <---- Sibling context --> 119 | ``` 120 | 121 | If you don't need to create child contexts, you can import the `wrap` convenience method 122 | by importing `com.colisweb.tracing.implicits._`. 123 | 124 | ```scala 125 | import com.colisweb.tracing.core.implicits._ 126 | 127 | val result: IO[Int] = tracingContextBuilder("Parent context", Map.empty) wrap IO { 128 | // Some work 129 | 78 + 12 130 | } 131 | ``` 132 | 133 | ## Working with monad transformers (OptionT, EitherT) 134 | 135 | Sometimes, you will need to trace a computation and get back an `EitherT[F, Error, B]` or `OptionT[F, A]` instead 136 | of a regular `F[A]`. For convinience, this library also provides the `either` and `option` operations on `Resource[F, A]`. 137 | 138 | ```scala 139 | import com.colisweb.tracing.core.implicits._ 140 | 141 | val computation: Option[IO, Int] = ??? 142 | 143 | val result: OptionT[IO, Int] = tracingContextBuilder("Parent context", Map.empty) option computation 144 | ``` 145 | 146 | ```scala 147 | import com.colisweb.tracing.core.implicits._ 148 | 149 | val computation: EitherT[IO, Throwable, Int] = ??? 150 | 151 | val result: EitherT[IO, Throwable, Int] = tracingContextBuilder("Parent context", Map.empty) either computation 152 | ``` 153 | 154 | ## Tracing Http4s services 155 | 156 | This library provides `TracedHttpRoutes[F[_]]`, a function that works just like `HttpRoutes.of` from Http4s, except in wraps 157 | all requests in a tracing context, which you can retrieve in your routes to instrument subsequent computations. 158 | 159 | To create a `TracedHttpRoutes[F[_]]`, you will need an implicit `TracingContextBuilder[F[_]]` in scope. You can then retrieve the 160 | tracing context with the `using` extractor from `com.colisweb.tracing.TracedHttpRoutes._` 161 | 162 | All of these functionnalities are present in the http package of the library. 163 | 164 | ```scala 165 | libraryDependencies += "com.colisweb" %% "scala-opentracing-http4s-server-tapir" % "2.5.0" 166 | ``` 167 | 168 | ```scala 169 | import org.http4s.dsl.io._ 170 | import com.colisweb.tracing.http.server.TracedHttpRoutes 171 | import com.colisweb.tracing.http.server.TracedHttpRoutes._ 172 | import com.colisweb.tracing.core.TracingContextBuilder 173 | 174 | object MyRoutes { 175 | def routes(implicit tracingContextBuilder: TracingContextBuilder[IO]): HttpRoutes[IO] = 176 | TracedHttpRoutes[IO] { 177 | case (req @ POST -> Root / "endpoint") using tracingContext => 178 | val result = tracingContext.span("Some computation") wrap IO { 179 | // Something here ... 180 | } 181 | 182 | result.flatMap(Ok(_)) 183 | } 184 | } 185 | ``` 186 | 187 | ### Using Tapir 188 | 189 | [tapir](https://github.com/softwaremill/tapir), or *Typed API descRiptions* is a fantastic library 190 | that allows you to define http endpoints using Scala's type system, and separate those definitions 191 | from your actual business logic. Tapir definitions can be *interpreted* into http4s `HttpRoutes`. 192 | 193 | The package `scala-opentracing-tapir` provides a small integration layer that allows you 194 | to create traced http endpoints from tapir `Endpoint` definitions. 195 | 196 | 197 | ```scala 198 | import cats.effect.IO 199 | import tapir._ 200 | import com.colisweb.tracing.http.server._ 201 | 202 | val myEndpoint: Endpoint[Unit, Unit, String, Nothing] = 203 | endpoint.get.in("/hello").out(stringBody) 204 | 205 | val routes: HttpRoutes[IO] = myEndpoint.toTracedRoute[IO]( 206 | (input, ctx) => ctx.span("Some description") use { _ => 207 | IO.pure(Right("OK")) 208 | } 209 | ) 210 | ``` 211 | 212 | You will need an implicit `ContextShift[F]` in scope for this to work. Take a look 213 | at the [tests](./tapir/src/test/scala/com/colisweb/tracing/tapir/TapirSpec.scala) for 214 | further examples. 215 | 216 | If your error type extends `Throwable`, you can also use `toTracedRouteRecoverErrors`, a traced 217 | equivalent of `toTracedRoute` from `tapir-http4s-server`. 218 | 219 | ## Correlating your logs 220 | 221 | If you want to link your traces and your logs together, you can do it manually by adding the `correlationId` of the context 222 | to your messages. A `PureLogger` is available in the `core` module. It wraps a logger with a `Sync[_]`. 223 | 224 | ```scala 225 | 226 | import com.colisweb.tracing.core.PureLogger 227 | import org.slf4j.{Logger, LoggerFactory} 228 | 229 | val logger: Logger = LoggerFactory.getLogger(getClass) 230 | val pureLogger: PureLogger[IO] = PureLogger(logger) 231 | 232 | val result: IO[Int] = tracingContextBuilder("Heavy mathematics", Map.empty) use { ctx => 233 | pureLogger.debug("Doing stuff. correlation id : {}", ctx.correlationId) *> IO { 234 | // Some computation here 235 | } 236 | } 237 | ``` 238 | 239 | ### Automatic correlation for Datadog 240 | 241 | `DDTracingContext[F[_]]` possess internally a `spanId` and a `traceId`. We provide a wrapper 242 | around `Logger` that is `TracingLogger` from SLF4J that will automatically 243 | add the `spanId` and `traceId` to your logs. The logging side-effect is already wrapped in `F` for 244 | purity. 245 | 246 | You can directly use the `TracingLogger` to set your own `markers`. These will be added to all logs. 247 | 248 | ```scala 249 | import org.http4s.dsl.io._ 250 | import com.typesafe.scalalogging.StrictLogging 251 | import com.colisweb.tracing.http.server.TracedHttpRoutes 252 | import com.colisweb.tracing.http.server.TracedHttpRoutes._ 253 | import com.colisweb.tracing.core._ 254 | 255 | object MyRoutes extends StrictLogging { 256 | // You will need an implicit Logger from slf4j to use the logging facility 257 | implicit val slf4jLogger: org.slf4j.Logger = logger.underlying 258 | 259 | def routes(implicit tracingContextBuilder: TracingContextBuilder[IO]): HttpRoutes[IO] = 260 | TracedHttpRoutes[IO] { 261 | case (req @ POST -> Root / "endpoint") using tracingContext => 262 | val result = tracingContext.span("Some computation") use { ctx => 263 | ctx.logger.debug("Doing stuff") *> IO { 264 | // Something here ... 265 | } 266 | } 267 | 268 | result.flatMap(Ok(_)) 269 | } 270 | } 271 | ``` 272 | 273 | ## Contributing 274 | 275 | Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. 276 | 277 | Make sure to follow our [Code of Conduct](./CODE_OF_CONDUCT.md). 278 | 279 | Here are some ideas of improvements : 280 | 281 | - Right now this library does not support distributed tracing, i.e the ability to continue a serialized 282 | trace from another application and/or send an unfinished trace to another application. 283 | - The `LoggingTracingContext` does not support tags at the moment. Adding tags to a `LoggingTracingContext` will have 284 | no effect. 285 | 286 | ## License 287 | 288 | [MIT](./LICENSE.md) 289 | -------------------------------------------------------------------------------- /amqp/src/main/scala/com/colisweb/application/context/amqp/AmqpConsumerWithCorrelationIdHelper.scala: -------------------------------------------------------------------------------- 1 | package com.colisweb.application.context.amqp 2 | 3 | import java.util.UUID 4 | 5 | import dev.profunktor.fs2rabbit.model.AmqpEnvelope 6 | import fs2.Stream 7 | 8 | trait AmqpConsumerWithCorrelationIdHelper { 9 | 10 | implicit final class AmqpConsumerWithCorrelationId[F[_], T](stream: Stream[F, AmqpEnvelope[T]]) { 11 | 12 | def withCorrelationId: Stream[F, AmqpEnvelope[T]] = stream.map(enrichWithCorrelationId) 13 | 14 | private def enrichWithCorrelationId(envelope: AmqpEnvelope[T]): AmqpEnvelope[T] = { 15 | val properties = envelope.properties 16 | val correlationId = properties.correlationId.orElse(Some(UUID.randomUUID.toString)) 17 | val propertiesWithId = properties.copy(correlationId = correlationId) 18 | envelope.copy(properties = propertiesWithId) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /amqp/src/main/scala/com/colisweb/application/context/amqp/package.scala: -------------------------------------------------------------------------------- 1 | package com.colisweb.application.context 2 | 3 | package object amqp extends AmqpConsumerWithCorrelationIdHelper 4 | -------------------------------------------------------------------------------- /amqp/src/test/scala/com/colisweb/application/context/amqp/AmqpMessageSpec.scala: -------------------------------------------------------------------------------- 1 | package com.colisweb.application.context.amqp 2 | 3 | import dev.profunktor.fs2rabbit.model._ 4 | import fs2.Stream 5 | import org.scalatest.flatspec.AnyFlatSpec 6 | 7 | final class AmqpMessageSpec extends AnyFlatSpec { 8 | 9 | final val deliveryTag = DeliveryTag(0) 10 | final val payload = "dumb message" 11 | final val exchangeName = ExchangeName("") 12 | final val routingKey = RoutingKey("") 13 | 14 | final val initialCorrelationId = "30c3ed83-45be-4cd8-acb8-5f1a04fdb3dd" 15 | 16 | final val emptyProperties = AmqpProperties() 17 | final val propertiesWithCorrelationId = AmqpProperties(correlationId = Some(initialCorrelationId)) 18 | 19 | "a stream" should "add a correlation id to messages that do not have one" in { 20 | val envelopeWithoutCorrelationId = AmqpEnvelope( 21 | deliveryTag = deliveryTag, 22 | payload = payload, 23 | properties = emptyProperties, 24 | exchangeName = exchangeName, 25 | routingKey = RoutingKey(""), 26 | redelivered = false 27 | ) 28 | 29 | val envelopeWithCorrelationId = AmqpEnvelope( 30 | deliveryTag = deliveryTag, 31 | payload = payload, 32 | properties = propertiesWithCorrelationId, 33 | exchangeName = exchangeName, 34 | routingKey = RoutingKey(""), 35 | redelivered = false 36 | ) 37 | 38 | val stream = Stream(envelopeWithoutCorrelationId, envelopeWithCorrelationId).withCorrelationId 39 | 40 | stream.compile.toList.flatMap(_.properties.correlationId) match { 41 | case List(_, _) => succeed 42 | case _ => fail 43 | } 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | import CompileFlags._ 2 | 3 | lazy val scala213 = "2.13.1" 4 | lazy val scala212 = "2.12.14" 5 | lazy val supportedScalaVersions = List(scala213, scala212) 6 | 7 | ThisBuild / scalaVersion := scala213 8 | ThisBuild / parallelExecution := false 9 | ThisBuild / scalacOptions ++= crossScalacOptions(scalaVersion.value) 10 | ThisBuild / pushRemoteCacheTo := Some( 11 | MavenCache("local-cache", baseDirectory.value / sys.env.getOrElse("CACHE_PATH", "sbt-cache")) 12 | ) 13 | resolvers += Resolver.sonatypeRepo("releases") 14 | 15 | lazy val root = (project in file(".")).settings(skip in publish := true).aggregate(core, context, httpServer, httpClient, httpTest, amqp) 16 | 17 | lazy val amqp = Project(id = "scala-opentracing-amqp", base = file("amqp")).settings( 18 | crossScalaVersions := supportedScalaVersions, 19 | libraryDependencies ++= Seq( 20 | CompileTimeDependencies.fs2Rabbit, 21 | TestsDependencies.scalatest 22 | ) 23 | ) 24 | 25 | lazy val core = Project(id = "scala-opentracing-core", base = file("core")).settings( 26 | crossScalaVersions := supportedScalaVersions, 27 | libraryDependencies ++= Seq( 28 | CompileTimeDependencies.catsEffect, 29 | CompileTimeDependencies.log4catsSlf4j 30 | ) 31 | ) 32 | 33 | lazy val context = Project(id = "scala-opentracing-context", base = file("context")) 34 | .dependsOn(core) 35 | .settings( 36 | crossScalaVersions := supportedScalaVersions, 37 | libraryDependencies ++= Seq( 38 | CompileTimeDependencies.cats, 39 | CompileTimeDependencies.opentracingApi, 40 | CompileTimeDependencies.opentracingUtil, 41 | CompileTimeDependencies.opentracingDd, 42 | CompileTimeDependencies.scalaCompat, 43 | CompileTimeDependencies.scalaLogging, 44 | CompileTimeDependencies.logstashLogbackEncoder, 45 | TestsDependencies.scalatest, 46 | TestsDependencies.logback, 47 | compilerPlugin(CompileTimeDependencies.kindProjector) 48 | ) 49 | ) 50 | 51 | lazy val httpServer = Project(id = "scala-opentracing-http4s-server-tapir", base = file("http/server")) 52 | .dependsOn(context % "test->test;compile->compile") 53 | .settings( 54 | crossScalaVersions := supportedScalaVersions, 55 | libraryDependencies ++= Seq( 56 | CompileTimeDependencies.tapir, 57 | CompileTimeDependencies.tapirHttp4sServer, 58 | compilerPlugin(CompileTimeDependencies.kindProjector) 59 | ) 60 | ) 61 | 62 | lazy val httpClient = Project(id = "scala-opentracing-http4s-client-blaze", base = file("http/client")) 63 | .dependsOn(context) 64 | .settings( 65 | crossScalaVersions := supportedScalaVersions, 66 | libraryDependencies ++= Seq(CompileTimeDependencies.http4s) 67 | ) 68 | 69 | lazy val httpTest = Project(id = "scala-opentracing-http4s-test", base = file("http/test")) 70 | .dependsOn(httpClient, httpServer % "test->test;compile->compile") 71 | .settings( 72 | libraryDependencies ++= Seq( 73 | TestsDependencies.circe, 74 | TestsDependencies.circeGeneric, 75 | TestsDependencies.circeGenericExtras, 76 | TestsDependencies.circeHttp4sCirce, 77 | TestsDependencies.tapirJsonCirce, 78 | TestsDependencies.requests, 79 | TestsDependencies.wiremock, 80 | TestsDependencies.http4sBlazeClient 81 | ), 82 | skip in publish := true 83 | ) 84 | -------------------------------------------------------------------------------- /context/src/main/scala/com/colisweb/tracing/context/LoggingTracingContext.scala: -------------------------------------------------------------------------------- 1 | package com.colisweb.tracing.context 2 | 3 | import cats.data.OptionT 4 | import cats.effect._ 5 | import cats.effect.concurrent.Ref 6 | import cats.syntax.all._ 7 | import com.colisweb.tracing.core._ 8 | import com.typesafe.scalalogging.StrictLogging 9 | import org.slf4j.Logger 10 | 11 | import scala.concurrent.duration.MILLISECONDS 12 | 13 | /** A tracing context that will log the beginning and the end of all traces along with 14 | * their tags. 15 | * The traces will be emitted with a TRACE level, so make sure to configure your logging backend 16 | * to ennable the TRACE level for com.colisweb.tracing 17 | */ 18 | class LoggingTracingContext[F[_]: Sync: Timer]( 19 | traceIdP: String, 20 | spanIdP: String, 21 | tagsRef: Ref[F, Tags], 22 | override val correlationId: String 23 | ) extends TracingContext[F] { 24 | 25 | def spanId: OptionT[F, String] = OptionT.pure(spanIdP) 26 | def traceId: OptionT[F, String] = OptionT.pure(traceIdP) 27 | 28 | def addTags(tags: Tags): F[Unit] = tagsRef.update(_ ++ tags) 29 | 30 | def span( 31 | operationName: String, 32 | tags: Tags 33 | ): TracingContextResource[F] = 34 | LoggingTracingContext(Some(this), correlationId = correlationId)(operationName) 35 | 36 | override def logger(implicit slf4jLogger: Logger): PureLogger[F] = PureLogger[F](slf4jLogger) 37 | } 38 | 39 | object LoggingTracingContext extends StrictLogging { 40 | 41 | /** Returns a Resource[F, TracingContext[F]]. The first log will be emitted 42 | * as the resource is acquired, the second log when it is released. 43 | */ 44 | def apply[F[_]: Sync: Timer]( 45 | parentContext: Option[LoggingTracingContext[F]] = None, 46 | idGenerator: Option[F[String]] = None, 47 | slf4jLogger: org.slf4j.Logger = logger.underlying, 48 | correlationId: String 49 | )( 50 | operationName: String, 51 | tags: Tags = Map.empty 52 | ): TracingContextResource[F] = 53 | resource(parentContext, idGenerator, slf4jLogger, operationName, correlationId).evalMap(ctx => ctx.addTags(tags).map(_ => ctx)) 54 | 55 | private def resource[F[_]: Sync: Timer]( 56 | parentContext: Option[LoggingTracingContext[F]], 57 | idGenerator: Option[F[String]], 58 | slf4jLogger: org.slf4j.Logger, 59 | operationName: String, 60 | correlationId: String 61 | ): TracingContextResource[F] = { 62 | val logger = PureLogger(slf4jLogger) 63 | val idGeneratorValue: F[String] = idGenerator.getOrElse(randomUUIDGenerator) 64 | val traceIdF: F[String] = 65 | OptionT.fromOption(parentContext).flatMap(_.traceId).getOrElseF(idGeneratorValue) 66 | 67 | val acquire: F[SpanDetails[F]] = for { 68 | tagsRef <- Ref[F].of[Tags](Map.empty) 69 | spanId <- idGeneratorValue 70 | traceId <- traceIdF 71 | start <- Clock[F].monotonic(MILLISECONDS) 72 | ctx = new LoggingTracingContext[F](traceId, spanId, tagsRef, correlationId) 73 | details = SpanDetails(start, traceId, spanId, ctx, tagsRef) 74 | _ <- logger.trace("Trace {} Starting Span {} ({})", traceId, spanId, operationName) 75 | } yield details 76 | 77 | def release(input: SpanDetails[F]): F[Unit] = input match { 78 | case SpanDetails(start, traceId, spanId, _, tagsRef) => 79 | for { 80 | tags <- tagsRef.get 81 | end <- Clock[F].monotonic(MILLISECONDS) 82 | duration = end - start 83 | _ <- logger.trace( 84 | "Trace {} Finished Span {} ({}) in {}ms. Tags: {}", 85 | traceId, 86 | spanId, 87 | operationName, 88 | duration.toString, 89 | tags.toString 90 | ) 91 | } yield () 92 | } 93 | 94 | Resource.make(acquire)(release).map(_.ctx) 95 | } 96 | 97 | private def randomUUIDGenerator[F[_]: Sync] = Sync[F].delay(java.util.UUID.randomUUID().toString) 98 | 99 | private case class SpanDetails[F[_]]( 100 | start: Long, 101 | traceId: String, 102 | spanId: String, 103 | ctx: LoggingTracingContext[F], 104 | tagsRef: Ref[F, Tags] 105 | ) 106 | 107 | /** Returns a F[TracingContextBuilder[F]] 108 | * 109 | * This is provided for convenience and consistency with regards to the other 110 | * tracing contexts types. 111 | */ 112 | def builder[F[_]: Sync: Timer]: F[TracingContextBuilder[F]] = 113 | Sync[F].delay((operationName: String, tags: Tags, correlationId: String) => 114 | LoggingTracingContext.apply(correlationId = correlationId)(operationName, tags) 115 | ) 116 | } 117 | -------------------------------------------------------------------------------- /context/src/main/scala/com/colisweb/tracing/context/NoOpTracingContext.scala: -------------------------------------------------------------------------------- 1 | package com.colisweb.tracing.context 2 | 3 | import cats.effect.{Resource, Sync} 4 | import com.colisweb.tracing.core._ 5 | import org.slf4j.Logger 6 | 7 | /** A tracing context that does nothing (no measurement, no log). This is useful 8 | * as a mock implementation for your tests of if you need to disable tracing 9 | * conditionally 10 | */ 11 | class NoOpTracingContext[F[_]: Sync](override val correlationId: String) extends TracingContext[F] { 12 | 13 | def addTags(tags: Tags): F[Unit] = Sync[F].unit 14 | 15 | def span(operationName: String, tags: Tags): TracingContextResource[F] = 16 | Resource.pure(NoOpTracingContext(correlationId)) 17 | 18 | override def logger(implicit slf4jLogger: Logger): PureLogger[F] = PureLogger[F](slf4jLogger) 19 | } 20 | 21 | object NoOpTracingContext { 22 | def apply[F[_]: Sync](correlationId: String) = new NoOpTracingContext[F](correlationId) 23 | 24 | def builder[F[_]: Sync](): F[TracingContextBuilder[F]] = 25 | Sync[F].delay((_: String, _: Tags, correlationId: String) => Resource.pure(NoOpTracingContext(correlationId = correlationId))) 26 | 27 | } 28 | -------------------------------------------------------------------------------- /context/src/main/scala/com/colisweb/tracing/context/OpenTracingContext.scala: -------------------------------------------------------------------------------- 1 | package com.colisweb.tracing.context 2 | 3 | import cats.effect._ 4 | import cats.implicits._ 5 | import com.colisweb.tracing.core._ 6 | import com.typesafe.scalalogging.StrictLogging 7 | import io.opentracing._ 8 | import io.opentracing.util.GlobalTracer 9 | import org.slf4j.Logger 10 | 11 | /** This is meant to be used with any OpenTracing compatible tracer. 12 | * For usage with Datadog APM, use DDTracingContext instead 13 | */ 14 | class OpenTracingContext[F[_]: Sync, T <: Tracer, S <: Span]( 15 | tracer: T, 16 | span: S, 17 | override val correlationId: String 18 | ) extends TracingContext[F] { 19 | 20 | def span( 21 | operationName: String, 22 | tags: Tags = Map.empty 23 | ): TracingContextResource[F] = 24 | OpenTracingContext[F, T, S]( 25 | tracer, 26 | Some(span), 27 | correlationId 28 | )( 29 | operationName, 30 | tags 31 | ) 32 | 33 | def addTags(tags: Tags): F[Unit] = Sync[F].delay { 34 | tags.foreach { case (key, value) => 35 | span.setTag(key, value) 36 | } 37 | } 38 | 39 | override def logger(implicit slf4jLogger: Logger): PureLogger[F] = PureLogger[F](slf4jLogger) 40 | } 41 | 42 | object OpenTracingContext extends StrictLogging { 43 | 44 | /** Creates a Resource[F, TracingContext[F]]. The underlying span will 45 | * be automatically closed when the Resource is released. 46 | */ 47 | def apply[F[_]: Sync, T <: Tracer, S <: Span]( 48 | tracer: T, 49 | parentSpan: Option[S] = None, 50 | correlationId: String 51 | )( 52 | operationName: String, 53 | tags: Tags = Map.empty 54 | ): TracingContextResource[F] = 55 | spanResource(tracer, operationName, parentSpan) 56 | .map(new OpenTracingContext(tracer, _, correlationId)) 57 | .evalMap(ctx => ctx.addTags(tags).map(_ => ctx)) 58 | 59 | /** Registers the tracer as the GlobalTracer and returns a F[TracingContextBuilder[F]]. 60 | * This may be necessary depending on the concrete tracing system you use. 61 | */ 62 | def builder[F[_]: Sync, T <: Tracer, S <: Span](tracer: T): F[TracingContextBuilder[F]] = { 63 | for { 64 | _ <- Sync[F].delay(GlobalTracer.registerIfAbsent(tracer)) 65 | } yield new TracingContextBuilder[F] { 66 | override def build(operationName: String, tags: Tags, correlationId: String): TracingContextResource[F] = 67 | OpenTracingContext(tracer, correlationId = correlationId)(operationName, tags) 68 | } 69 | } 70 | 71 | private[tracing] def spanResource[F[_]: Sync, T <: Tracer, S <: Span]( 72 | tracer: T, 73 | operationName: String, 74 | parentSpan: Option[S] = None 75 | ): Resource[F, S] = { 76 | val acquire: F[S] = { 77 | val spanBuilder = { 78 | val span = tracer.buildSpan(operationName) 79 | val spanWithParent = parentSpan match { 80 | case Some(s) => span.asChildOf(s) 81 | case None => span 82 | } 83 | spanWithParent 84 | } 85 | Sync[F].delay { spanBuilder.start().asInstanceOf[S] } 86 | } 87 | 88 | def release(s: S): F[Unit] = Sync[F].delay(s.finish()) 89 | 90 | Resource.make(acquire)(release) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /context/src/main/scala/com/colisweb/tracing/context/datadog/DDTracingContext.scala: -------------------------------------------------------------------------------- 1 | package com.colisweb.tracing.context.datadog 2 | 3 | import _root_.datadog.trace.api.DDTags.SERVICE_NAME 4 | import _root_.datadog.trace.api.{GlobalTracer => DDGlobalTracer} 5 | import cats.data.OptionT 6 | import cats.effect._ 7 | import cats.syntax.all._ 8 | import com.colisweb.tracing.context.logging.TracingLogger 9 | import com.colisweb.tracing.core._ 10 | import com.typesafe.scalalogging.StrictLogging 11 | import datadog.opentracing.DDTracer 12 | import io.opentracing.util.GlobalTracer 13 | import io.opentracing.{Span, Tracer} 14 | import net.logstash.logback.marker.Markers.appendEntries 15 | import org.slf4j.{Logger, Marker} 16 | 17 | import scala.jdk.CollectionConverters._ 18 | 19 | /** This tracing context is intended to be used with Datadog APM. 20 | * It adds the Service Name tag to all spans, required to see the traces in the APM 21 | * view of Datadog. 22 | * It also provides access to span id and trace id to correlate logs and traces together. 23 | */ 24 | class DDTracingContext[F[_]: Sync]( 25 | protected val tracer: Tracer, 26 | protected val span: Span, 27 | protected val serviceName: String, 28 | override val correlationId: String 29 | ) extends TracingContext[F] { 30 | 31 | def traceId: OptionT[F, String] = 32 | OptionT.liftF(Sync[F] delay { 33 | span.context().toTraceId 34 | }) 35 | 36 | def spanId: OptionT[F, String] = 37 | OptionT.liftF(Sync[F] delay { 38 | span.context().toSpanId 39 | }) 40 | 41 | override def span(operationName: String, tags: Tags = Map.empty): TracingContextResource[F] = 42 | DDTracingContext.apply[F](tracer, serviceName, Some(span), correlationId)(operationName, tags) 43 | 44 | override def addTags(tags: Tags): F[Unit] = 45 | Sync[F].delay { 46 | tags.foreach { case (key, value) => 47 | span.setTag(key, value) 48 | } 49 | } 50 | 51 | override def logger(implicit slf4jLogger: Logger): PureLogger[F] = 52 | TracingLogger.pureTracingLogger[F](slf4jLogger, markers) 53 | 54 | private lazy val markers: F[Marker] = { 55 | 56 | val traceIdMarker = traceId.map(id => Map("dd.trace_id" -> id)).getOrElse(Map.empty) 57 | val spanIdMarker = 58 | spanId.map(id => Map("dd.span_id" -> id)).getOrElse(Map.empty) 59 | for { 60 | spanId <- spanIdMarker 61 | traceId <- traceIdMarker 62 | } yield appendEntries( 63 | (traceId ++ spanId).asJava 64 | ) 65 | 66 | } 67 | } 68 | 69 | object DDTracingContext extends StrictLogging { 70 | def builder[F[_]: Sync](name: String): F[TracingContextBuilder[F]] = { 71 | for { 72 | tracer <- buildAndRegisterDDTracer 73 | } yield { (operationName: String, tags: Tags, correlationId: String) => 74 | { 75 | DDTracingContext.apply( 76 | tracer = tracer, 77 | serviceName = name, 78 | correlationId = correlationId 79 | )(operationName, tags) 80 | } 81 | } 82 | } 83 | 84 | def apply[F[_]: Sync]( 85 | tracer: Tracer, 86 | serviceName: String, 87 | parentSpan: Option[Span] = None, 88 | correlationId: String 89 | )( 90 | operationName: String, 91 | tags: Tags 92 | ): TracingContextResource[F] = 93 | DDTracingContext 94 | .spanResource[F](tracer, operationName, parentSpan) 95 | .map(new DDTracingContext(tracer, _, serviceName, correlationId)) 96 | .evalMap(ctx => ctx.addTags(tags + (SERVICE_NAME -> serviceName)).map(_ => ctx)) 97 | 98 | private[tracing] def spanResource[F[_]: Sync]( 99 | tracer: Tracer, 100 | operationName: String, 101 | parentSpan: Option[Span] = None 102 | ): Resource[F, Span] = { 103 | def acquire: F[Span] = { 104 | val spanBuilder = { 105 | val span = tracer.buildSpan(operationName) 106 | val spanWithParent = parentSpan match { 107 | case Some(s) => span.asChildOf(s) 108 | case None => span 109 | } 110 | spanWithParent 111 | } 112 | Sync[F].delay { 113 | spanBuilder.start() 114 | } 115 | } 116 | 117 | def release(s: Span): F[Unit] = Sync[F].delay(s.finish()) 118 | 119 | Resource.make(acquire)(release) 120 | } 121 | 122 | private def buildAndRegisterDDTracer[F[_]: Sync]: F[Tracer] = Sync[F].delay { 123 | if (GlobalTracer.isRegistered) { 124 | GlobalTracer.get() 125 | } else { 126 | val tracer = DDTracer.builder().build() 127 | DDGlobalTracer.registerIfAbsent(tracer) 128 | tracer 129 | } 130 | } 131 | 132 | } 133 | -------------------------------------------------------------------------------- /context/src/main/scala/com/colisweb/tracing/context/logging/TracingLogger.scala: -------------------------------------------------------------------------------- 1 | package com.colisweb.tracing.context.logging 2 | 3 | import cats.effect.Sync 4 | import cats.syntax.all._ 5 | import com.colisweb.tracing.core.PureLogger 6 | import org.slf4j.{Logger, Marker} 7 | 8 | object TracingLogger { 9 | 10 | /** A pure logger that will automatically log SpanId and TraceId from 11 | * a given TracingContext. The logging side effects will be lifted into 12 | * the tracing context's F monad. 13 | * 14 | * You must provide a way of creating a slf4j Marker from the TracingContext. 15 | */ 16 | def pureTracingLogger[F[_]: Sync]( 17 | logger: Logger, 18 | ctxMarker: F[Marker] 19 | ): PureLogger[F] = { 20 | val pureLogger = PureLogger[F](logger) 21 | def addCtxMarker(marker: Option[Marker]): F[Marker] = 22 | ctxMarker.map(m => combineMarkers(marker, m)) 23 | 24 | new PureLogger[F] { 25 | def trace(msg: String, args: Object*): F[Unit] = 26 | addCtxMarker(None).flatMap(m => pureLogger.trace(m, msg, args: _*)) 27 | def trace(marker: Marker, msg: String, args: Object*): F[Unit] = 28 | addCtxMarker(Some(marker)).flatMap(m => pureLogger.trace(m, msg, args: _*)) 29 | def trace(msg: String, throwable: Throwable): F[Unit] = 30 | addCtxMarker(None).flatMap(_ => pureLogger.trace(msg, throwable)) 31 | 32 | def debug(msg: String, args: Object*): F[Unit] = 33 | addCtxMarker(None).flatMap(m => pureLogger.debug(m, msg, args: _*)) 34 | def debug(marker: Marker, msg: String, args: Object*): F[Unit] = 35 | addCtxMarker(Some(marker)).flatMap(m => pureLogger.debug(m, msg, args: _*)) 36 | def debug(msg: String, throwable: Throwable): F[Unit] = 37 | addCtxMarker(None).flatMap(_ => pureLogger.debug(msg, throwable)) 38 | 39 | def info(msg: String, args: Object*): F[Unit] = 40 | addCtxMarker(None).flatMap(m => pureLogger.info(m, msg, args: _*)) 41 | def info(marker: Marker, msg: String, args: Object*): F[Unit] = 42 | addCtxMarker(Some(marker)).flatMap(m => pureLogger.info(m, msg, args: _*)) 43 | def info(msg: String, throwable: Throwable): F[Unit] = 44 | addCtxMarker(None).flatMap(_ => pureLogger.info(msg, throwable)) 45 | 46 | def warn(msg: String, args: Object*): F[Unit] = 47 | addCtxMarker(None).flatMap(m => pureLogger.warn(m, msg, args: _*)) 48 | def warn(marker: Marker, msg: String, args: Object*): F[Unit] = 49 | addCtxMarker(Some(marker)).flatMap(m => pureLogger.warn(m, msg, args: _*)) 50 | def warn(msg: String, throwable: Throwable): F[Unit] = 51 | addCtxMarker(None).flatMap(_ => pureLogger.warn(msg, throwable)) 52 | 53 | def error(msg: String, args: Object*): F[Unit] = 54 | addCtxMarker(None).flatMap(m => pureLogger.error(m, msg, args: _*)) 55 | def error(marker: Marker, msg: String, args: Object*): F[Unit] = 56 | addCtxMarker(Some(marker)).flatMap(m => pureLogger.error(m, msg, args: _*)) 57 | def error(msg: String, throwable: Throwable): F[Unit] = 58 | addCtxMarker(None).flatMap(_ => pureLogger.error(msg, throwable)) 59 | 60 | } 61 | } 62 | 63 | def combineMarkers(a: Option[Marker], b: Marker): Marker = 64 | a match { 65 | case Some(a) => { a.add(b); a } 66 | case _ => b 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /context/src/test/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /context/src/test/scala/com/colisweb/tracing/context/LogCorrelationSpec.scala: -------------------------------------------------------------------------------- 1 | package com.colisweb.tracing.context 2 | 3 | import java.util.UUID 4 | 5 | import cats.data._ 6 | import cats.effect._ 7 | import com.colisweb.tracing.context.datadog.DDTracingContext 8 | import com.colisweb.tracing.core.{Tags, TracingContext, TracingContextResource} 9 | import com.typesafe.scalalogging.StrictLogging 10 | import org.scalatest.funspec.AnyFunSpec 11 | import org.scalatest.matchers.should.Matchers 12 | import TestUtils._ 13 | import _root_.datadog.opentracing._ 14 | 15 | class LogCorrelationSpec extends AnyFunSpec with StrictLogging with Matchers { 16 | 17 | implicit val slf4jLogger: org.slf4j.Logger = logger.underlying 18 | 19 | describe("Datadog log correlation") { 20 | it("Should log trace id as a JSON field when TracingContext has a trace id") { 21 | val traceId = UUID.randomUUID().toString 22 | val context = mockDDTracingContext(OptionT.none, OptionT.pure(traceId)) 23 | testStdOut( 24 | context.logger.info("Hello"), 25 | _ should include( 26 | s""""dd.trace_id":"$traceId"""" 27 | ) 28 | ) 29 | } 30 | 31 | it("Should log span id as JSON field when TracingContext has a span id") { 32 | val spanId = UUID.randomUUID().toString 33 | val context = mockDDTracingContext(OptionT.pure(spanId), OptionT.none) 34 | testStdOut( 35 | context.logger.info("Hello"), 36 | _ should include( 37 | s""""dd.span_id":"$spanId"""" 38 | ) 39 | ) 40 | } 41 | 42 | it("Should not add anything when TracingContext has neither a span id nor a trace id") { 43 | val context = mockDDTracingContext(OptionT.none, OptionT.none) 44 | testStdOut( 45 | context.logger.info("Hello"), 46 | _ should (not include ("trace_id") and not include ("span_id")) 47 | ) 48 | } 49 | } 50 | 51 | private def mockDDTracingContext( 52 | _spanId: OptionT[IO, String], 53 | _traceId: OptionT[IO, String] 54 | ): TracingContext[IO] = { 55 | val tracer = DDTracer.builder().build() 56 | val span = tracer.activeSpan() 57 | 58 | new DDTracingContext[IO](tracer, span, "Mocked service", UUID.randomUUID().toString) { 59 | override def span(operationName: String, tags: Tags): TracingContextResource[IO] = ??? 60 | override def spanId: cats.data.OptionT[cats.effect.IO, String] = _spanId 61 | override def traceId: cats.data.OptionT[cats.effect.IO, String] = _traceId 62 | override def addTags(tags: Tags): cats.effect.IO[Unit] = ??? 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /context/src/test/scala/com/colisweb/tracing/context/LoggingTracingContextSpec.scala: -------------------------------------------------------------------------------- 1 | package com.colisweb.tracing.context 2 | 3 | import cats.effect.IO 4 | import TestUtils._ 5 | import com.typesafe.scalalogging.StrictLogging 6 | import org.scalatest.funspec.AnyFunSpec 7 | import org.scalatest.matchers.should.Matchers 8 | 9 | import scala.concurrent.duration._ 10 | 11 | class LoggingTracingContextSpec extends AnyFunSpec with Matchers with StrictLogging { 12 | describe("LoggingTracingContext") { 13 | 14 | it("Should log at the start and the end on the operation") { 15 | 16 | val operationName = "My Operation" 17 | val context = LoggingTracingContext[IO](correlationId = "")(operationName) 18 | val operation: IO[LoggingTracingContext[IO]] = context use { ctx => 19 | IO.sleep(100 millis).map(_ => ctx.asInstanceOf[LoggingTracingContext[IO]]) 20 | } 21 | 22 | testStdOut[LoggingTracingContext[IO]]( 23 | operation, 24 | (stdOut, ctx) => { 25 | val traceId: String = ctx.traceId.value.unsafeRunSync().get 26 | val spanId: String = ctx.spanId.value.unsafeRunSync().get 27 | stdOut should ( 28 | include(s"Trace ${traceId} Starting Span ${spanId} ($operationName)") 29 | and include(s"Finished Span ${spanId}") 30 | ) 31 | } 32 | ) 33 | } 34 | 35 | it("Should preserve Trace ids across child contexts") { 36 | (LoggingTracingContext[IO](correlationId = "")("Parent") use { parent => 37 | val parentCtx = parent.asInstanceOf[LoggingTracingContext[IO]] 38 | parentCtx.span("Child context") use { child => 39 | val childCtx = child.asInstanceOf[LoggingTracingContext[IO]] 40 | IO { 41 | parentCtx.traceId.value.unsafeRunSync() shouldBe childCtx.traceId.value.unsafeRunSync() 42 | parentCtx.spanId.value.unsafeRunSync() shouldNot be( 43 | childCtx.spanId.value.unsafeRunSync() 44 | ) 45 | } 46 | } 47 | }).unsafeRunSync() 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /context/src/test/scala/com/colisweb/tracing/context/TestUtils.scala: -------------------------------------------------------------------------------- 1 | package com.colisweb.tracing.context 2 | 3 | import scala.concurrent._ 4 | import scala.concurrent.duration._ 5 | import org.scalatest._ 6 | import cats.effect._ 7 | import java.io.ByteArrayOutputStream 8 | import java.io.PrintStream 9 | 10 | object TestUtils { 11 | implicit val timer: Timer[IO] = IO.timer(ExecutionContext.global) 12 | 13 | def testStdOut[A]( 14 | body: IO[A], 15 | assertion: (String, A) => Assertion 16 | ): Assertion = { 17 | val out = new ByteArrayOutputStream() 18 | val stdOut = System.out 19 | System.setOut(new PrintStream(out)) 20 | val result = body.unsafeRunSync() 21 | IO.sleep(200 millis).unsafeRunSync() 22 | System.setOut(stdOut) 23 | assertion(out.toString, result) 24 | } 25 | 26 | def testStdOut( 27 | body: IO[Unit], 28 | assertion: String => Assertion 29 | ) = testStdOut[Unit](body, (stdOut, _) => assertion(stdOut)) 30 | } 31 | -------------------------------------------------------------------------------- /core/src/main/scala/com/colisweb/tracing/core/PureLogger.scala: -------------------------------------------------------------------------------- 1 | package com.colisweb.tracing.core 2 | 3 | import cats.effect.Sync 4 | import org.slf4j.Marker 5 | 6 | /** A logger that wraps the side-effect of logging into 7 | * some algebraic effect F 8 | */ 9 | trait PureLogger[F[_]] { 10 | def trace(msg: String, args: Object*): F[Unit] 11 | def trace(marker: Marker, msg: String, args: Object*): F[Unit] 12 | def trace(msg: String, throwable: Throwable): F[Unit] 13 | 14 | def debug(msg: String, args: Object*): F[Unit] 15 | def debug(marker: Marker, msg: String, args: Object*): F[Unit] 16 | def debug(msg: String, throwable: Throwable): F[Unit] 17 | 18 | def info(msg: String, args: Object*): F[Unit] 19 | def info(marker: Marker, msg: String, args: Object*): F[Unit] 20 | def info(msg: String, throwable: Throwable): F[Unit] 21 | 22 | def warn(msg: String, args: Object*): F[Unit] 23 | def warn(marker: Marker, msg: String, args: Object*): F[Unit] 24 | def warn(msg: String, throwable: Throwable): F[Unit] 25 | 26 | def error(msg: String, args: Object*): F[Unit] 27 | def error(marker: Marker, msg: String, args: Object*): F[Unit] 28 | def error(msg: String, throwable: Throwable): F[Unit] 29 | } 30 | 31 | object PureLogger { 32 | 33 | def apply[F[_]: Sync](logger: org.slf4j.Logger): PureLogger[F] = 34 | new PureLogger[F] { 35 | 36 | def trace(marker: Marker, msg: String, args: Object*): F[Unit] = Sync[F].delay { 37 | logger.trace(marker, msg, args: _*) 38 | } 39 | def trace(msg: String, args: Object*): F[Unit] = Sync[F].delay { 40 | logger.trace(msg, args: _*) 41 | } 42 | def trace(msg: String, throwable: Throwable): F[Unit] = Sync[F].delay { 43 | logger.trace(msg, throwable) 44 | } 45 | 46 | def debug(marker: Marker, msg: String, args: Object*): F[Unit] = Sync[F].delay { 47 | logger.debug(marker, msg, args: _*) 48 | } 49 | def debug(msg: String, args: Object*): F[Unit] = Sync[F].delay { 50 | logger.debug(msg, args: _*) 51 | } 52 | def debug(msg: String, throwable: Throwable): F[Unit] = Sync[F].delay { 53 | logger.debug(msg, throwable) 54 | } 55 | 56 | def info(marker: Marker, msg: String, args: Object*): F[Unit] = Sync[F].delay { 57 | logger.info(marker, msg, args: _*) 58 | } 59 | def info(msg: String, args: Object*): F[Unit] = Sync[F].delay { 60 | logger.info(msg, args: _*) 61 | } 62 | def info(msg: String, throwable: Throwable): F[Unit] = Sync[F].delay { 63 | logger.info(msg, throwable) 64 | } 65 | 66 | def warn(marker: Marker, msg: String, args: Object*): F[Unit] = Sync[F].delay { 67 | logger.warn(marker, msg, args: _*) 68 | } 69 | def warn(msg: String, args: Object*): F[Unit] = Sync[F].delay { 70 | logger.warn(msg, args: _*) 71 | } 72 | def warn(msg: String, throwable: Throwable): F[Unit] = Sync[F].delay { 73 | logger.warn(msg, throwable) 74 | } 75 | 76 | def error(marker: Marker, msg: String, args: Object*): F[Unit] = Sync[F].delay { 77 | logger.error(marker, msg, args: _*) 78 | } 79 | def error(msg: String, args: Object*): F[Unit] = Sync[F].delay { 80 | logger.error(msg, args: _*) 81 | } 82 | def error(msg: String, throwable: Throwable): F[Unit] = Sync[F].delay { 83 | logger.error(msg, throwable) 84 | } 85 | } 86 | 87 | } 88 | -------------------------------------------------------------------------------- /core/src/main/scala/com/colisweb/tracing/core/TracingContext.scala: -------------------------------------------------------------------------------- 1 | package com.colisweb.tracing.core 2 | 3 | import org.slf4j.Logger 4 | 5 | /** Represents a computational context may have unique `spanId` and `traceId` identifiers and 6 | * should be able to spawn child context 7 | */ 8 | trait TracingContext[F[_]] { 9 | 10 | def addTags(tags: Tags): F[Unit] 11 | 12 | def logger(implicit slf4jLogger: Logger): PureLogger[F] 13 | 14 | /** This creates a new TracingContext. The trace id should preserved across children, and the children 15 | * must be closed in the reverse-order of their creation 16 | */ 17 | def span(operationName: String, tags: Tags = Map.empty): TracingContextResource[F] 18 | 19 | /** A correlation id will be the same for the context and its descendant 20 | * It is meant to follow the logging path across multiple services 21 | */ 22 | def correlationId: String 23 | } 24 | -------------------------------------------------------------------------------- /core/src/main/scala/com/colisweb/tracing/core/TracingContextBuilder.scala: -------------------------------------------------------------------------------- 1 | package com.colisweb.tracing.core 2 | 3 | import java.util.UUID 4 | 5 | trait TracingContextBuilder[F[_]] { 6 | protected def newCorrelationId: String = UUID.randomUUID().toString 7 | 8 | def build( 9 | operationName: String, 10 | tags: Tags = Map.empty, 11 | correlationId: String = newCorrelationId 12 | ): TracingContextResource[F] 13 | } 14 | -------------------------------------------------------------------------------- /core/src/main/scala/com/colisweb/tracing/core/implicits/Syntax.scala: -------------------------------------------------------------------------------- 1 | package com.colisweb.tracing.core.implicits 2 | 3 | import cats.effect._ 4 | import cats.data._ 5 | 6 | trait Syntax { 7 | implicit class ResourceOps[F[_]: Sync, A](resource: Resource[F, A]) { 8 | 9 | /** Allows ignoring the value of a Resource. 10 | * {{{ 11 | * import com.colisweb.tracing.implicits._ 12 | * 13 | * someTracingContext.childContext("Child operation") wrap F.delay { /* Some computation */ } 14 | * }}} 15 | * 16 | * is equivalent to 17 | * {{{ 18 | * someTracingContext.childContext("Child operation") use { _ => F.delay { /* Some computation */ } } 19 | * }}} 20 | */ 21 | def wrap[B](body: => F[B]): F[B] = resource.use(_ => body) 22 | 23 | /** Allows allocating a Resource and supplying it to a function returning 24 | * an EitherT. 25 | */ 26 | def either[L, R](body: A => EitherT[F, L, R]): EitherT[F, L, R] = 27 | EitherT(resource.use(a => body(a).value)) 28 | 29 | /** Allows allocating a Resource and supplying it to a function returning 30 | * an OptionT. 31 | */ 32 | def option[B](body: A => OptionT[F, B]): OptionT[F, B] = 33 | OptionT(resource.use(a => body(a).value)) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /core/src/main/scala/com/colisweb/tracing/core/implicits/package.scala: -------------------------------------------------------------------------------- 1 | package com.colisweb.tracing.core 2 | 3 | package object implicits extends Syntax 4 | -------------------------------------------------------------------------------- /core/src/main/scala/com/colisweb/tracing/core/package.scala: -------------------------------------------------------------------------------- 1 | package com.colisweb.tracing 2 | 3 | import cats.effect.Resource 4 | 5 | package object core { 6 | type Tags = Map[String, String] 7 | type TracingContextResource[F[_]] = Resource[F, TracingContext[F]] 8 | 9 | } 10 | -------------------------------------------------------------------------------- /http/client/src/main/scala/com/colisweb/tracing/http/client/RequestWithCorrelationIdHelper.scala: -------------------------------------------------------------------------------- 1 | package com.colisweb.tracing.http.client 2 | 3 | import java.util.UUID 4 | 5 | import com.colisweb.tracing.core.TracingContext 6 | import org.http4s.{Header, Request} 7 | 8 | trait RequestWithCorrelationIdHelper { 9 | 10 | implicit final class RequestWithCorrelationId[F[_]](req: Request[F]) { 11 | 12 | final val correlationIdHeaderName = "X-Correlation-Id" 13 | 14 | private def injectCorrelationIdToRequest(correlationId: String): Request[F] = { 15 | val correlationIdHeader = Header(correlationIdHeaderName, correlationId) 16 | req.putHeaders(correlationIdHeader) 17 | } 18 | 19 | def withCorrelationId: Request[F] = injectCorrelationIdToRequest(UUID.randomUUID().toString) 20 | 21 | def withCorrelationId(context: TracingContext[F]): Request[F] = 22 | injectCorrelationIdToRequest(context.correlationId) 23 | 24 | } 25 | 26 | } -------------------------------------------------------------------------------- /http/client/src/main/scala/com/colisweb/tracing/http/client/package.scala: -------------------------------------------------------------------------------- 1 | package com.colisweb.tracing.http 2 | 3 | package object client extends RequestWithCorrelationIdHelper 4 | -------------------------------------------------------------------------------- /http/server/src/main/scala/com/colisweb/tracing/http/server/TracedHttpRoutes.scala: -------------------------------------------------------------------------------- 1 | package com.colisweb.tracing.http.server 2 | 3 | import java.util.UUID 4 | 5 | import cats.data._ 6 | import cats.effect._ 7 | import cats.implicits._ 8 | import com.colisweb.tracing.core.{TracingContext, TracingContextBuilder} 9 | import io.opentracing._ 10 | import io.opentracing.tag.Tags._ 11 | import org.http4s._ 12 | import org.http4s.util.CaseInsensitiveString 13 | 14 | object TracedHttpRoutes { 15 | 16 | final val correlationIdHeaderName = "X-Correlation-Id" 17 | type EnrichedRequest[F[_]] = (Request[F], String) 18 | 19 | def enrichRequest[F[_]](request: Request[F]): EnrichedRequest[F] = { 20 | val idHeader = request.headers.get(CaseInsensitiveString(correlationIdHeaderName)) 21 | 22 | val correlationId = idHeader.fold(UUID.randomUUID.toString)(_.value) 23 | val enrichedHeader = Header(correlationIdHeaderName, correlationId) 24 | 25 | (request.putHeaders(enrichedHeader), correlationId) 26 | } 27 | 28 | def apply[F[_]: Sync]( 29 | pf: PartialFunction[TracedRequest[F], F[Response[F]]] 30 | )(implicit 31 | builder: TracingContextBuilder[F] 32 | ): HttpRoutes[F] = { 33 | val tracedRoutes = Kleisli[OptionT[F, *], TracedRequest[F], Response[F]] { req => 34 | pf.andThen(OptionT.liftF(_)).applyOrElse(req, Function.const(OptionT.none)) 35 | } 36 | wrapHttpRoutes(tracedRoutes, builder) 37 | } 38 | 39 | def wrapHttpRoutes[F[_]: Sync]( 40 | routes: Kleisli[OptionT[F, *], TracedRequest[F], Response[F]], 41 | builder: TracingContextBuilder[F] 42 | ): HttpRoutes[F] = { 43 | Kleisli[OptionT[F, *], Request[F], Response[F]] { req => 44 | val (enrichedRequest, correlationId) = enrichRequest(req) 45 | 46 | val operationName = "http4s-incoming-request" 47 | val tags = Map( 48 | HTTP_METHOD.getKey -> enrichedRequest.method.name, 49 | HTTP_URL.getKey -> enrichedRequest.uri.path.toString 50 | ) 51 | 52 | OptionT { 53 | builder.build(operationName, tags, correlationId) use { context => 54 | val tracedRequest = TracedRequest[F](enrichedRequest, context) 55 | val responseOptionWithTags = routes.run(tracedRequest) semiflatMap { response => 56 | val tags = Map( 57 | HTTP_STATUS.getKey -> response.status.code.toString 58 | ) ++ 59 | response.headers.toList.map(h => (s"http.response.header.${h.name}" -> h.value)).toMap 60 | context.addTags(tags).map(_ => response).map(_.putHeaders(Header(correlationIdHeaderName, correlationId))) 61 | } 62 | responseOptionWithTags.value 63 | } 64 | } 65 | } 66 | } 67 | 68 | object using { 69 | def unapply[F[_], T <: Tracer, S <: Span]( 70 | tr: TracedRequest[F] 71 | ): Option[(Request[F], TracingContext[F])] = 72 | Some(tr.request -> tr.tracingContext) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /http/server/src/main/scala/com/colisweb/tracing/http/server/TracedRequest.scala: -------------------------------------------------------------------------------- 1 | package com.colisweb.tracing.http.server 2 | 3 | import com.colisweb.tracing.core.TracingContext 4 | import org.http4s.Request 5 | 6 | case class TracedRequest[F[_]](request: Request[F], tracingContext: TracingContext[F]) 7 | -------------------------------------------------------------------------------- /http/server/src/main/scala/com/colisweb/tracing/http/server/TracedRoutes.scala: -------------------------------------------------------------------------------- 1 | package com.colisweb.tracing.http.server 2 | 3 | import cats.data._ 4 | import cats.effect._ 5 | import com.colisweb.tracing.core.{TracingContext, TracingContextBuilder} 6 | import org.http4s._ 7 | import sttp.tapir.Endpoint 8 | import sttp.tapir.server.http4s.Http4sServerInterpreter.{toRouteRecoverErrors, toRoutes} 9 | import sttp.tapir.server.http4s._ 10 | 11 | import scala.reflect.ClassTag 12 | 13 | trait TracedRoutes { 14 | 15 | implicit class TracedEndpoint[In, Err, Out](e: Endpoint[In, Err, Out, Any]) { 16 | 17 | def toTracedRoute[F[_]: Sync: Concurrent: Timer](logic: (In, TracingContext[F]) => F[Either[Err, Out]])(implicit 18 | builder: TracingContextBuilder[F], 19 | cs: ContextShift[F], 20 | serverOptions: Http4sServerOptions[F] 21 | ): HttpRoutes[F] = { 22 | 23 | TracedHttpRoutes.wrapHttpRoutes( 24 | Kleisli[OptionT[F, *], TracedRequest[F], Response[F]] { req => 25 | toRoutes(e)(input => logic(input, req.tracingContext))( 26 | serverOptions, 27 | implicitly, 28 | cs, 29 | implicitly 30 | ).run(req.request) 31 | }, 32 | builder 33 | ) 34 | } 35 | } 36 | 37 | implicit class TracedEndpointRecoverErrors[In, Err <: Throwable, Out]( 38 | e: Endpoint[In, Err, Out, Any] 39 | ) { 40 | def toTracedRouteRecoverErrors[F[_]: Sync: Concurrent: Timer](logic: (In, TracingContext[F]) => F[Out])(implicit 41 | builder: TracingContextBuilder[F], 42 | eClassTag: ClassTag[Err], 43 | cs: ContextShift[F], 44 | serverOptions: Http4sServerOptions[F] 45 | ): HttpRoutes[F] = 46 | TracedHttpRoutes.wrapHttpRoutes( 47 | Kleisli[OptionT[F, *], TracedRequest[F], Response[F]] { req => 48 | toRouteRecoverErrors(e)(input => logic(input, req.tracingContext))( 49 | serverOptions, 50 | implicitly, 51 | implicitly, 52 | implicitly, 53 | implicitly, 54 | implicitly 55 | ).run(req.request) 56 | }, 57 | builder 58 | ) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /http/server/src/main/scala/com/colisweb/tracing/http/server/package.scala: -------------------------------------------------------------------------------- 1 | package com.colisweb.tracing.http 2 | 3 | package object server extends TracedRoutes 4 | -------------------------------------------------------------------------------- /http/server/src/test/scala/com/colisweb/tracing/http/server/TapirSpec.scala: -------------------------------------------------------------------------------- 1 | package com.colisweb.tracing.http.server 2 | 3 | import java.util.UUID 4 | 5 | import cats.data.OptionT 6 | import cats.effect.concurrent.Deferred 7 | import cats.effect.{ContextShift, IO, Resource, Timer} 8 | import com.colisweb.tracing.core._ 9 | import org.http4s.Request 10 | import org.scalatest.funspec.AsyncFunSpec 11 | import org.scalatest.matchers.should.Matchers 12 | import org.slf4j.Logger 13 | import sttp.tapir.Codec.string 14 | import sttp.tapir._ 15 | import sttp.tapir.generic.auto._ 16 | 17 | import scala.concurrent.ExecutionContext 18 | 19 | class TapirSpec extends AsyncFunSpec with Matchers { 20 | implicit val timer : Timer[IO] = IO.timer(ExecutionContext.global) 21 | import TapirSpec._ 22 | 23 | describe("Tapir Integration") { 24 | it("Should create a tracing context and pass it to the logic function") { 25 | (for { 26 | tracingContextDeferred <- Deferred[IO, TracingContext[IO]] 27 | _ <- myEndpoint 28 | .toTracedRoute[IO]((_, ctx: TracingContext[IO]) => tracingContextDeferred.complete(ctx) *> IO.pure(Right("Ok"))) 29 | .run(request) 30 | .value 31 | tracingContext <- tracingContextDeferred.get 32 | receivedTracingContext = tracingContext.asInstanceOf[MockedTracingContext] 33 | } yield { 34 | receivedTracingContext.traceId shouldBe mockedContext.traceId 35 | receivedTracingContext.spanId shouldBe mockedContext.spanId 36 | }).unsafeToFuture() 37 | } 38 | 39 | it("Should serve a the correct response when the endpoint is called with a valid request") { 40 | val output = java.util.UUID.randomUUID().toString 41 | myEndpoint 42 | .toTracedRoute[IO]((_, _) => IO.pure(Right(output))) 43 | .run(request) 44 | .value 45 | .map(_.get) 46 | .flatMap(_.as[String]) 47 | .map(res => res shouldBe output) 48 | .unsafeToFuture() 49 | } 50 | 51 | it("Should serve an error when an exception is thrown from the endpoint logic") { 52 | val endpointWithError = myEndpoint.errorOut(plainBody[EndpointError](endpointErrorCodec)) 53 | endpointWithError 54 | .toTracedRouteRecoverErrors[IO]((_, _) => IO.raiseError(EndpointError("Something terrible happened"))) 55 | .run(request) 56 | .value 57 | .map(_.get) 58 | .flatMap(_.as[String]) 59 | .map(res => res shouldBe "Message: Something terrible happened") 60 | .unsafeToFuture() 61 | } 62 | } 63 | 64 | } 65 | object TapirSpec { 66 | implicit def endpointErrorCodec: Codec[String, EndpointError, CodecFormat.TextPlain] = 67 | string.map(EndpointError)(error => s"Message: ${error.message}").schema(implicitly[Schema[EndpointError]]) 68 | 69 | implicit def mockedContextBuilder: TracingContextBuilder[IO] = 70 | (_, _, _) => Resource.pure[IO, TracingContext[IO]](mockedContext) 71 | 72 | implicit def cs: ContextShift[IO] = IO.contextShift(ExecutionContext.global) 73 | 74 | case class EndpointError(message: String) extends RuntimeException 75 | 76 | val myEndpoint: Endpoint[Unit, Unit, String, Any] = 77 | endpoint.get.in("").name("My endpoint").out(stringBody) 78 | 79 | val request: Request[IO] = Request[IO]() 80 | 81 | val randomSpanId: String = java.util.UUID.randomUUID().toString 82 | 83 | val mockedContext = new MockedTracingContext 84 | 85 | class MockedTracingContext extends TracingContext[IO] { 86 | 87 | def spanId: OptionT[IO, String] = OptionT.pure(randomSpanId) 88 | def traceId: OptionT[IO, String] = OptionT.pure(randomSpanId) 89 | override def correlationId: String = UUID.randomUUID().toString 90 | override def logger(implicit slf4jLogger: Logger): PureLogger[IO] = PureLogger(slf4jLogger) 91 | 92 | def addTags(tags: Tags): cats.effect.IO[Unit] = IO.unit 93 | def span( 94 | operationName: String, 95 | tags: Tags 96 | ): TracingContextResource[cats.effect.IO] = ??? 97 | } 98 | 99 | } 100 | -------------------------------------------------------------------------------- /http/server/src/test/scala/com/colisweb/tracing/http/server/TracedEndpointSpec.scala: -------------------------------------------------------------------------------- 1 | package com.colisweb.tracing.http.server 2 | 3 | import cats.effect.{ContextShift, IO, Timer} 4 | import com.colisweb.tracing.context.NoOpTracingContext 5 | import com.colisweb.tracing.core.{TracingContext, TracingContextBuilder} 6 | import com.colisweb.tracing.http.server.TracedHttpRoutes._ 7 | import org.http4s.Request 8 | import org.http4s.util.CaseInsensitiveString 9 | import org.scalatest.flatspec.AnyFlatSpec 10 | import org.scalatest.matchers.should.Matchers 11 | import sttp.tapir.endpoint 12 | 13 | import scala.concurrent.ExecutionContext 14 | 15 | final class TracedEndpointSpec extends AnyFlatSpec with Matchers { 16 | 17 | implicit val contextShift: ContextShift[IO] = IO.contextShift(ExecutionContext.global) 18 | implicit val timer: Timer[IO] = IO.timer(ExecutionContext.global) 19 | implicit val tcb: TracingContextBuilder[IO] = NoOpTracingContext.builder[IO]().unsafeRunSync() 20 | 21 | def dumbLogic: (Unit, TracingContext[IO]) => IO[Either[Unit, Unit]] = (_, _) => IO(Right(())) 22 | 23 | "A response" should "reuse the request's correlation id if it exists" in { 24 | val (enrichedRequest, correlationId) = enrichRequest(Request[IO]()) 25 | val response = endpoint.toTracedRoute[IO](dumbLogic).run(enrichedRequest).value.unsafeRunSync.get 26 | 27 | val correlationHeader = response.headers.get(CaseInsensitiveString(correlationIdHeaderName)) 28 | val responseCorrelationId = correlationHeader.get.value 29 | 30 | responseCorrelationId should equal(correlationId) 31 | } 32 | 33 | "A response" should "contain a new correlation id if the request does not contain one" in { 34 | val request: Request[IO] = Request() 35 | 36 | val response = endpoint.toTracedRoute[IO](dumbLogic).run(request).value.unsafeRunSync.get 37 | 38 | val maybeResponseCorrelationId = 39 | response.headers.get(CaseInsensitiveString(correlationIdHeaderName)) 40 | 41 | maybeResponseCorrelationId should not be empty 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /http/test/src/test/scala/com/colisweb/tracing/http/test/CorrelationIdEndToEndTest.scala: -------------------------------------------------------------------------------- 1 | package com.colisweb.tracing.http.test 2 | 3 | import cats.effect.{ContextShift, ExitCode, IO, Resource, Timer} 4 | import com.github.tomakehurst.wiremock.WireMockServer 5 | import com.github.tomakehurst.wiremock.client.WireMock 6 | import com.github.tomakehurst.wiremock.client.WireMock._ 7 | import com.github.tomakehurst.wiremock.core.WireMockConfiguration 8 | import com.github.tomakehurst.wiremock.extension.responsetemplating.ResponseTemplateTransformer 9 | import io.circe.generic.auto._ 10 | import io.circe.parser._ 11 | import org.http4s.server.Server 12 | import org.scalatest.BeforeAndAfterAll 13 | import org.scalatest.flatspec.AnyFlatSpec 14 | import org.scalatest.matchers.should.Matchers 15 | 16 | import scala.concurrent.{ExecutionContext, ExecutionContextExecutor} 17 | 18 | final class CorrelationIdEndToEndTest extends AnyFlatSpec with Matchers with BeforeAndAfterAll { 19 | 20 | implicit val ec: ExecutionContextExecutor = ExecutionContext.global 21 | implicit val cs: ContextShift[IO] = IO.contextShift(ec) 22 | implicit val timer: Timer[IO] = IO.timer(ec) 23 | 24 | val (server1Port, server2Port) = (freePort, freePort) 25 | 26 | lazy val (wireMockServer, wireMock) = { 27 | val wireMockConfiguration = 28 | WireMockConfiguration.options().port(server2Port).extensions(new ResponseTemplateTransformer(true)) 29 | (new WireMockServer(wireMockConfiguration), new WireMock("localhost", server2Port)) 30 | } 31 | 32 | val server1: Resource[IO, Server[IO]] = TestServer.create[IO](serverPort = server1Port, server2Port = server2Port) 33 | 34 | override def beforeAll(): Unit = { 35 | super.beforeAll() 36 | wireMockServer.start() 37 | wireMock.register( 38 | get(urlPathEqualTo("/where_the_weed_at")).willReturn( 39 | aResponse() 40 | .withHeader("Content-Type", "application/json") 41 | .withBody("""{ "correlationId": "{{request.headers.X-Correlation-Id}}" }""") 42 | ) 43 | ) 44 | server1.use(_ => IO.never).as(ExitCode.Success).unsafeRunAsyncAndForget() 45 | } 46 | 47 | override def afterAll(): Unit = { 48 | super.afterAll() 49 | wireMockServer.stop() 50 | } 51 | 52 | "emitting a request to the first server with a correlation id" should "give us the same correlation id in the response" in { 53 | val correlationId = "50c76f05-b422-47ac-86b5-5691a68f1ac9" 54 | val response = 55 | requests.get( 56 | url = s"http://localhost:$server1Port/pass_the_weed", 57 | headers = List(("x-correlation-id", correlationId)) 58 | ) 59 | 60 | decode[WrappedCorrelationId](response.text()) match { 61 | case Right(WrappedCorrelationId(c)) => c shouldBe correlationId 62 | case _ => fail 63 | } 64 | } 65 | 66 | "emitting a request to the first server without a correlation id" should "generate a correlation id in the response" in { 67 | val response = requests.get(url = s"http://localhost:$server1Port/pass_the_weed") 68 | 69 | decode[WrappedCorrelationId](response.text()) match { 70 | case Right(_) => succeed 71 | case _ => fail 72 | } 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /http/test/src/test/scala/com/colisweb/tracing/http/test/TestServer.scala: -------------------------------------------------------------------------------- 1 | package com.colisweb.tracing.http.test 2 | 3 | import cats.Applicative 4 | import cats.effect.{ConcurrentEffect, ContextShift, Resource, Sync, Timer} 5 | import cats.implicits._ 6 | import com.colisweb.tracing.context.NoOpTracingContext 7 | import com.colisweb.tracing.core.TracingContextBuilder 8 | import org.http4s.client.Client 9 | import org.http4s.client.blaze.BlazeClientBuilder 10 | import org.http4s.implicits._ 11 | import org.http4s.server.Server 12 | import org.http4s.server.blaze.BlazeServerBuilder 13 | import org.http4s.{HttpApp, Request, Uri} 14 | 15 | import scala.concurrent.ExecutionContext 16 | 17 | object TestServer { 18 | 19 | def create[F[_]: ConcurrentEffect: Timer: ContextShift]( 20 | serverPort: Int, 21 | server2Port: Int 22 | ): Resource[F, Server[F]] = 23 | for { 24 | tracingContextBuilder <- Resource.eval(NoOpTracingContext.builder()) 25 | exCtx = ExecutionContext.global 26 | client <- BlazeClientBuilder[F](exCtx).resource 27 | service = new ServerService(client, server2Port) 28 | server <- BlazeServerBuilder.apply(exCtx).bindLocal(serverPort).withHttpApp(service.routes(tracingContextBuilder)).resource 29 | } yield server 30 | 31 | } 32 | 33 | final class ServerService[F[_]: Sync: ContextShift: Timer: ConcurrentEffect]( 34 | client: Client[F], 35 | server2Port: Int 36 | ) { 37 | 38 | import com.colisweb.tracing.http.client._ 39 | import com.colisweb.tracing.http.server._ 40 | import org.http4s.Method._ 41 | 42 | final val F = Applicative[F] 43 | 44 | def routes(implicit tracingContextBuilder: TracingContextBuilder[F]): HttpApp[F] = 45 | greetEndpointDefinition 46 | .toTracedRoute[F] { (_, context) => 47 | val server2GreetingsEndpoint: Uri = 48 | Uri.unsafeFromString(s"http://localhost:$server2Port/where_the_weed_at") 49 | client 50 | .run(Request[F](method = GET, uri = server2GreetingsEndpoint).withCorrelationId(context)) 51 | .use(response => response.as[WrappedCorrelationId].map(Right(_))) 52 | } 53 | .orNotFound 54 | 55 | } 56 | -------------------------------------------------------------------------------- /http/test/src/test/scala/com/colisweb/tracing/http/test/WrappedCorrelationId.scala: -------------------------------------------------------------------------------- 1 | package com.colisweb.tracing.http.test 2 | 3 | final case class WrappedCorrelationId(correlationId: String) 4 | -------------------------------------------------------------------------------- /http/test/src/test/scala/com/colisweb/tracing/http/test/package.scala: -------------------------------------------------------------------------------- 1 | package com.colisweb.tracing.http 2 | 3 | import java.net.ServerSocket 4 | 5 | import cats.effect.{IO, Resource, Sync} 6 | import io.circe.generic.auto._ 7 | import org.http4s.EntityDecoder 8 | import org.http4s.circe._ 9 | import sttp.tapir.Codec.JsonCodec 10 | import sttp.tapir._ 11 | import sttp.tapir.json.circe._ 12 | import sttp.tapir.generic.auto._ 13 | 14 | package object test { 15 | 16 | implicit def responseWithCorrelationIdEntityDecoder[F[_]: Sync]: EntityDecoder[F, WrappedCorrelationId] = 17 | jsonOf[F, WrappedCorrelationId] 18 | 19 | implicit val responseWithCorrelationIdCodec: JsonCodec[WrappedCorrelationId] = 20 | circeCodec[WrappedCorrelationId] 21 | 22 | def greetEndpointDefinition: Endpoint[Unit, Unit, WrappedCorrelationId, Any] = 23 | endpoint.get.in("pass_the_weed").out(jsonBody[WrappedCorrelationId]) 24 | 25 | def freePort: Int = 26 | Resource.fromAutoCloseable(IO(new ServerSocket(0))).use(serverSocket => IO(serverSocket.getLocalPort)).unsafeRunSync() 27 | 28 | } 29 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Colisweb/scala-opentracing/69fc2c836bd746ad31c65bd14348c58c4f1f9bab/logo.png -------------------------------------------------------------------------------- /project/CompileFlags.scala: -------------------------------------------------------------------------------- 1 | import sbt.librarymanagement.CrossVersion 2 | 3 | object CompileFlags { 4 | val flags = Seq( 5 | "-deprecation", // Emit warning and location for usages of deprecated APIs. 6 | "-encoding", 7 | "utf-8", // Specify character encoding used by source files. 8 | "-explaintypes", // Explain type errors in more detail. 9 | "-feature", // Emit warning and location for usages of features that should be imported explicitly. 10 | "-language:postfixOps", 11 | "-language:existentials", // Existential types (besides wildcard types) can be written and inferred 12 | "-language:experimental.macros", // Allow macro definition (besides implementation and application) 13 | "-language:higherKinds", // Allow higher-kinded types 14 | "-language:implicitConversions", // Allow definition of implicit functions called views 15 | "-unchecked", // Enable additional warnings where generated code depends on assumptions. 16 | "-Xcheckinit", // Wrap field accessors to throw an exception on uninitialized access. 17 | "-Xfatal-warnings", // Fail the compilation if there are any warnings. 18 | "-Xlint:adapted-args", // Warn if an argument list is modified to match the receiver. 19 | "-Xlint:constant", // Evaluation of a constant arithmetic expression results in an error. 20 | "-Xlint:delayedinit-select", // Selecting member of DelayedInit. 21 | "-Xlint:doc-detached", // A Scaladoc comment appears to be detached from its element. 22 | "-Xlint:inaccessible", // Warn about inaccessible types in method signatures. 23 | "-Xlint:infer-any", // Warn when a type argument is inferred to be `Any`. 24 | "-Xlint:missing-interpolator", // A string literal appears to be missing an interpolator id. 25 | "-Xlint:nullary-override", // Warn when non-nullary `def f()' overrides nullary `def f'. 26 | "-Xlint:nullary-unit", // Warn when nullary methods return Unit. 27 | "-Xlint:option-implicit", // Option.apply used implicit view. 28 | "-Xlint:poly-implicit-overload", // Parameterized overloaded implicit methods are not visible as view bounds. 29 | "-Xlint:private-shadow", // A private field (or class parameter) shadows a superclass field. 30 | "-Xlint:stars-align", // Pattern sequence wildcard must align with sequence component. 31 | "-Xlint:type-parameter-shadow", // A local type parameter shadows a type already in scope. 32 | "-Ywarn-dead-code", // Warn when dead code is identified. 33 | "-Ywarn-extra-implicit", // Warn when more than one implicit parameter section is defined. 34 | "-Ywarn-numeric-widen", // Warn when numerics are widened. 35 | "-Ywarn-unused:implicits", // Warn if an implicit parameter is unused. 36 | "-Ywarn-unused:imports", // Warn if an import selector is not referenced. 37 | "-Ywarn-unused:locals", // Warn if a local definition is unused. 38 | "-Ywarn-unused:params", // Warn if a value parameter is unused. 39 | "-Ywarn-unused:patvars", // Warn if a variable bound in a pattern is unused. 40 | "-Ywarn-unused:privates", // Warn if a private member is unused. 41 | "-Ywarn-value-discard" // Warn when non-Unit expression results are unused. 42 | ) 43 | 44 | val flags212 = Seq( 45 | "-Xfuture", // Turn on future language features. 46 | "-Yno-adapted-args", // Do not adapt an argument list (either by inserting () or creating a tuple) to match the receiver. 47 | "-Xlint:by-name-right-associative", // By-name parameter of right associative operator. 48 | "-Xlint:unsound-match", // Pattern match may not be typesafe. 49 | "-Ypartial-unification", // Enable partial unification in type constructor inference 50 | "-Ywarn-inaccessible", // Warn about inaccessible types in method signatures. 51 | "-Ywarn-infer-any", // Warn when a type argument is inferred to be `Any`. 52 | "-Ywarn-nullary-override", // Warn when non-nullary `def f()' overrides nullary `def f'. 53 | "-Ywarn-nullary-unit" // Warn when nullary methods return Unit. 54 | ) 55 | 56 | val flags213 = Seq( 57 | "-Xlint:adapted-args", // Warn if an argument list is modified to match the receiver. 58 | "-Xlint:inaccessible", 59 | "-Xlint:infer-any", 60 | "-Xlint:nullary-override" 61 | ) 62 | 63 | def crossScalacOptions(version: String): Seq[String] = 64 | CrossVersion.partialVersion(version) match { 65 | case Some((2L, 13L)) => flags ++ flags213 66 | case Some((2L, 12L)) => flags ++ flags212 67 | case _ => Seq.empty 68 | } 69 | } -------------------------------------------------------------------------------- /project/CompileTimeDependencies.scala: -------------------------------------------------------------------------------- 1 | import sbt._ 2 | 3 | object Versions { 4 | 5 | final val cats = "2.6.1" 6 | final val catsEffect = "2.5.3" 7 | final val circe = "0.14.1" 8 | final val datadog = "0.68.0" 9 | final val fs2 = "4.1.0" 10 | final val http4s = "0.21.26" 11 | final val kindProjector = "0.13.1" 12 | final val logback = "1.2.5" 13 | final val logstash = "6.6" 14 | final val log4cats = "1.3.1" 15 | final val opentracing = "0.33.0" 16 | final val requests = "0.6.9" 17 | final val scalaCompat = "2.5.0" 18 | final val scalaLogging = "3.9.4" 19 | final val scalatest = "3.2.9" 20 | final val scalaCollection = "2.1.4" 21 | final val tapir = "0.17.20" 22 | final val wiremock = "2.27.2" 23 | } 24 | 25 | object CompileTimeDependencies { 26 | final val cats = "org.typelevel" %% "cats-core" % Versions.cats 27 | final val catsEffect = "org.typelevel" %% "cats-effect" % Versions.catsEffect 28 | final val fs2Rabbit = "dev.profunktor" %% "fs2-rabbit" % Versions.fs2 29 | final val http4s = "org.http4s" %% "http4s-core" % Versions.http4s 30 | final val kindProjector = "org.typelevel" %% "kind-projector" % Versions.kindProjector cross CrossVersion.full 31 | final val logstashLogbackEncoder = "net.logstash.logback" % "logstash-logback-encoder" % Versions.logstash 32 | final val log4catsSlf4j = "org.typelevel" %% "log4cats-slf4j" % Versions.log4cats 33 | final val opentracingApi = "io.opentracing" % "opentracing-api" % Versions.opentracing 34 | final val opentracingDd = "com.datadoghq" % "dd-trace-ot" % Versions.datadog 35 | final val opentracingUtil = "io.opentracing" % "opentracing-util" % Versions.opentracing 36 | final val scalaCollectionsCompat = "org.scala-lang.modules" %% "scala-collection-compat" % Versions.scalaCollection 37 | final val scalaLogging = "com.typesafe.scala-logging" %% "scala-logging" % Versions.scalaLogging 38 | final val tapir = "com.softwaremill.sttp.tapir" %% "tapir-core" % Versions.tapir 39 | final val tapirHttp4sServer = "com.softwaremill.sttp.tapir" %% "tapir-http4s-server" % Versions.tapir 40 | final val scalaCompat = "org.scala-lang.modules" %% "scala-collection-compat" % Versions.scalaCompat 41 | } 42 | 43 | object TestsDependencies { 44 | 45 | final val circe = "io.circe" %% "circe-core" % Versions.circe % Test 46 | final val circeGeneric = "io.circe" %% "circe-generic" % Versions.circe % Test 47 | final val circeGenericExtras = "io.circe" %% "circe-generic-extras" % Versions.circe % Test 48 | final val circeHttp4sCirce = "org.http4s" %% "http4s-circe" % Versions.http4s % Test 49 | final val http4sBlazeClient = "org.http4s" %% "http4s-blaze-client" % Versions.http4s % Test 50 | final val logback = "ch.qos.logback" % "logback-classic" % Versions.logback % Test 51 | final val requests = "com.lihaoyi" %% "requests" % Versions.requests % Test 52 | final val scalatest = "org.scalatest" %% "scalatest" % Versions.scalatest % Test 53 | final val tapirJsonCirce = "com.softwaremill.sttp.tapir" %% "tapir-json-circe" % Versions.tapir % Test 54 | final val wiremock = "com.github.tomakehurst" % "wiremock" % Versions.wiremock % Test 55 | } 56 | -------------------------------------------------------------------------------- /project/ReleaseSettings.scala: -------------------------------------------------------------------------------- 1 | ../scala-common/ReleaseSettings.scala -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.5.5 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "3.9.7") 2 | addSbtPlugin("com.github.sbt" % "sbt-pgp" % "2.1.2") 3 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.3") 4 | addSbtPlugin("io.github.davidgregory084" % "sbt-tpolecat" % "0.1.20") 5 | addSbtPlugin("com.github.cb372" % "sbt-explicit-dependencies" % "0.2.16") 6 | -------------------------------------------------------------------------------- /publish.sbt: -------------------------------------------------------------------------------- 1 | ReleaseSettings.globalReleaseSettings 2 | ReleaseSettings.buildReleaseSettings( 3 | "Scala Opentracing is a Scala wrapper around the Opentracing library for Java", 4 | "MIT", 5 | "http://opensource.org/licenses/MIT", 6 | "scala-opentracing" 7 | ) 8 | 9 | ThisBuild / developers := List( 10 | Developers.cyrilVerdier, 11 | Developers.sallaReznov, 12 | Developers.colasMombrun 13 | ) 14 | -------------------------------------------------------------------------------- /secrets-env.sh.secret: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Colisweb/scala-opentracing/69fc2c836bd746ad31c65bd14348c58c4f1f9bab/secrets-env.sh.secret --------------------------------------------------------------------------------