├── .gitignore ├── .mergify.yml ├── .scalafmt.conf ├── .travis.yml ├── README.md ├── build.sbt ├── monix-logback-http4s └── src │ ├── main │ └── scala │ │ └── com │ │ └── softwaremill │ │ └── correlator │ │ └── Http4sCorrelationMiddleware.scala │ └── test │ ├── resources │ └── logback.xml │ └── scala │ └── com │ └── softwaremill │ └── correlator │ └── CorrelationIdDecoratorTest.scala ├── monix-logback └── src │ └── main │ └── scala │ └── com │ └── softwaremill │ └── correlator │ └── CorrelationIdDecorator.scala ├── project ├── build.properties └── plugins.sbt ├── scripts └── decrypt_files_if_not_pr.sh ├── secrets.tar.enc └── version.sbt /.gitignore: -------------------------------------------------------------------------------- 1 | *.class 2 | *.log 3 | 4 | .cache 5 | .history 6 | .lib/ 7 | dist/* 8 | target/ 9 | lib_managed/ 10 | src_managed/ 11 | project/boot/ 12 | project/plugins/project/ 13 | 14 | .idea* 15 | -------------------------------------------------------------------------------- /.mergify.yml: -------------------------------------------------------------------------------- 1 | pull_request_rules: 2 | - name: delete head branch after merge 3 | conditions: [] 4 | actions: 5 | delete_head_branch: {} 6 | - name: automatic merge for scala-steward pull requests affecting build.sbt 7 | conditions: 8 | - author=scala-steward 9 | - status-success=continuous-integration/travis-ci/pr 10 | - "#files=1" 11 | - files=build.sbt 12 | actions: 13 | merge: 14 | method: merge 15 | - name: automatic merge for scala-steward pull requests affecting project plugins.sbt 16 | conditions: 17 | - author=scala-steward 18 | - status-success=continuous-integration/travis-ci/pr 19 | - "#files=1" 20 | - files=project/plugins.sbt 21 | actions: 22 | merge: 23 | method: merge 24 | - name: semi-automatic merge for scala-steward pull requests 25 | conditions: 26 | - author=scala-steward 27 | - status-success=continuous-integration/travis-ci/pr 28 | - "#approved-reviews-by>=1" 29 | actions: 30 | merge: 31 | method: merge 32 | - name: automatic merge for scala-steward pull requests affecting project build.properties 33 | conditions: 34 | - author=scala-steward 35 | - status-success=continuous-integration/travis-ci/pr 36 | - "#files=1" 37 | - files=project/build.properties 38 | actions: 39 | merge: 40 | method: merge 41 | - name: automatic merge for scala-steward pull requests affecting .scalafmt.conf 42 | conditions: 43 | - author=scala-steward 44 | - status-success=continuous-integration/travis-ci/pr 45 | - "#files=1" 46 | - files=.scalafmt.conf 47 | actions: 48 | merge: 49 | method: merge 50 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = 2.7.5 2 | maxColumn = 140 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: scala 2 | jdk: 3 | - openjdk8 4 | scala: 5 | - 2.12.10 6 | - 2.13.1 7 | before_install: 8 | - bash scripts/decrypt_files_if_not_pr.sh 9 | before_cache: 10 | - du -h -d 1 $HOME/.ivy2/ 11 | - du -h -d 2 $HOME/.sbt/ 12 | - du -h -d 4 $HOME/.coursier/ 13 | - find $HOME/.sbt -name "*.lock" -type f -delete 14 | - find $HOME/.ivy2/cache -name "ivydata-*.properties" -type f -delete 15 | - find $HOME/.coursier/cache -name "*.lock" -type f -delete 16 | cache: 17 | directories: 18 | - "$HOME/.sbt/1.0" 19 | - "$HOME/.sbt/boot/scala*" 20 | - "$HOME/.sbt/cache" 21 | - "$HOME/.sbt/launchers" 22 | - "$HOME/.ivy2/cache" 23 | - "$HOME/.coursier" 24 | script: 25 | - sbt ++$TRAVIS_SCALA_VERSION test 26 | deploy: 27 | - provider: script 28 | script: sbt publishRelease 29 | skip_cleanup: true 30 | on: 31 | all_branches: true 32 | condition: $TRAVIS_SCALA_VERSION = "2.12.10" && $TRAVIS_TAG =~ ^v[0-9]+\.[0-9]+(\.[0-9]+)? 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Correlation id support 2 | 3 | [![Build Status](https://travis-ci.org/softwaremill/correlator.svg?branch=master)](https://travis-ci.org/softwaremill/correlator) 4 | 5 | See [the blog](https://blog.softwaremill.com/correlation-ids-in-scala-using-monix-3aa11783db81) for introduction. 6 | 7 | Currently supports [monix](https://monix.io) & [logback](https://logback.qos.ch). 8 | 9 | Generic usage: 10 | 11 | * add the dependency: `"com.softwaremill.correlator" %% "monix-logback-http4s" % "0.1.9"` to your project 12 | * create an object extending the `CorrelationIdDecorator` class, e.g. `object MyCorrelationId extends CorrelationIdDecorator()` 13 | * call `MyCorrelationId.init()` immediately after your program starts (in the `main()` method) 14 | * create an implicit instance of `CorrelationIdSource` for given `T` from which you want to extract correlation id 15 | * wrap any `T => Task[R]` function with `MyCorrelationId.withCorrelationId`, so that a correlation id is 16 | extracted from the argument (using the defined `CorrelationIdSource`), or a new one is created. 17 | * you can access the current correlation id (if any is set) using `MyCorrelationId.apply()` or `MyCorrelationId.applySync()`. 18 | 19 | 20 | For [http4s](https://http4s.org) integration: 21 | 22 | * add the dependency: `"com.softwaremill.correlator" %% "monix-logback-http4s" % "0.1.9"` to your project 23 | * create an object extending the `CorrelationIdDecorator` class, e.g. `object MyCorrelationId extends CorrelationIdDecorator()` 24 | * call `MyCorrelationId.init()` immediately after your program starts (in the `main()` method) 25 | * wrap your `HttpRoutes[Task]` with `Http4sCorrelationMiddleware(MyCorrelationId).withCorrelationId`, so that a correlation id is 26 | extracted from the request (using the provided header name), or a new one is created. 27 | * you can access the current correlation id (if any is set) using `MyCorrelationId.apply()` or `MyCorrelationId.applySync()`. 28 | 29 | Logging each request with corresponding correlationId can be done in following way: 30 | ```scala 31 | def loggingMiddleware[T, R]( 32 | service: HttpRoutes[Task], 33 | logStartRequest: Request[Task] => Task[Unit] = req => 34 | Task(MyLogger.debug(s"Starting request to: ${req.uri.path}")) 35 | ): HttpRoutes[Task] = Kleisli{ req: Request[Task] => 36 | val setupAndService = for { 37 | _ <- logStartRequest(req) 38 | r <- service(req).value 39 | } yield r 40 | OptionT(setupAndService) 41 | } 42 | 43 | val middleware = Http4sCorrelationMiddleware(correlationIdDecorator) 44 | middleware.withCorrelationId(loggingMiddleware(service)) 45 | ``` 46 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | import com.softwaremill.PublishTravis.publishTravisSettings 2 | 3 | lazy val scala212 = "2.12.10" 4 | lazy val scala213 = "2.13.6" 5 | 6 | lazy val supportedScalaVersions = List(scala212, scala213) 7 | 8 | lazy val commonSettings = commonSmlBuildSettings ++ ossPublishSettings ++ Seq( 9 | organization := "com.softwaremill.correlator", 10 | crossScalaVersions := supportedScalaVersions 11 | ) 12 | 13 | val scalaTest = "org.scalatest" %% "scalatest" % "3.2.9" % "test" 14 | 15 | lazy val rootProject = (project in file(".")) 16 | .settings(commonSettings: _*) 17 | .settings( 18 | publishArtifact := false, 19 | name := "root" 20 | ) 21 | .settings(publishTravisSettings) 22 | .aggregate(monixLogback, monixLogbackHttp4s) 23 | 24 | lazy val monixLogback: Project = (project in file("monix-logback")) 25 | .settings(commonSettings: _*) 26 | .settings( 27 | name := "monix-logback", 28 | libraryDependencies ++= Seq("io.monix" %% "monix" % "3.4.0", "ch.qos.logback" % "logback-classic" % "1.2.3", scalaTest) 29 | ) 30 | 31 | lazy val monixLogbackHttp4s: Project = (project in file("monix-logback-http4s")) 32 | .settings(commonSettings: _*) 33 | .settings( 34 | name := "monix-logback-http4s", 35 | libraryDependencies ++= Seq( 36 | "io.monix" %% "monix" % "3.4.0", 37 | "ch.qos.logback" % "logback-classic" % "1.2.3", 38 | "org.http4s" %% "http4s-core" % "0.21.24", 39 | "org.http4s" %% "http4s-dsl" % "0.21.24" % "test", 40 | scalaTest 41 | ) 42 | ) 43 | .dependsOn(monixLogback) 44 | -------------------------------------------------------------------------------- /monix-logback-http4s/src/main/scala/com/softwaremill/correlator/Http4sCorrelationMiddleware.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.correlator 2 | 3 | import cats.data.{Kleisli, OptionT} 4 | import com.softwaremill.correlator.CorrelationIdDecorator.CorrelationIdSource 5 | import monix.eval.Task 6 | import org.http4s.Request 7 | import org.http4s.util.CaseInsensitiveString 8 | 9 | class Http4sCorrelationMiddleware(correlationId: CorrelationIdDecorator) { 10 | 11 | def withCorrelationId[T, R]( 12 | service: Kleisli[OptionT[Task, *], T, R] 13 | )(implicit source: CorrelationIdSource[T]): Kleisli[OptionT[Task, *], T, R] = { 14 | val runOptionT: T => Task[Option[R]] = service.run.andThen(_.value) 15 | Kleisli(correlationId.withCorrelationId[T, Option[R]](runOptionT).andThen(OptionT.apply)) 16 | } 17 | } 18 | 19 | object Http4sCorrelationMiddleware { 20 | def apply(correlationId: CorrelationIdDecorator): Http4sCorrelationMiddleware = new Http4sCorrelationMiddleware(correlationId) 21 | 22 | val HeaderName: String = "X-Correlation-ID" 23 | 24 | implicit val source: CorrelationIdSource[Request[Task]] = (t: Request[Task]) => { 25 | t.headers.get(CaseInsensitiveString(HeaderName)).map(_.value) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /monix-logback-http4s/src/test/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | %d{HH:mm:ss.SSS}%boldYellow(%replace( [%X{cid}] ){' \[\] ', ' '})[%thread] %-5level %logger{5} - %msg%n%rEx 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /monix-logback-http4s/src/test/scala/com/softwaremill/correlator/CorrelationIdDecoratorTest.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.correlator 2 | 3 | import java.util.concurrent.ConcurrentLinkedQueue 4 | 5 | import monix.eval.Task 6 | import org.http4s._ 7 | import org.http4s.dsl.Http4sDsl 8 | import monix.execution.Scheduler.Implicits.global 9 | import org.slf4j.{Logger, LoggerFactory} 10 | import org.http4s.implicits._ 11 | import scala.collection.JavaConverters._ 12 | import org.scalatest.flatspec.AnyFlatSpec 13 | import org.scalatest.matchers.should.Matchers 14 | import Http4sCorrelationMiddleware._ 15 | 16 | class CorrelationIdDecoratorTest extends AnyFlatSpec with Matchers { 17 | TestCorrelationIdDecorator.init() 18 | 19 | private val logger: Logger = LoggerFactory.getLogger(getClass.getName) 20 | 21 | private val dsl = Http4sDsl[Task] 22 | import dsl._ 23 | 24 | "CorrelationId" should "create and pass to downstream http requests" in new Fixture { 25 | // given 26 | val request = Request[Task](method = GET, uri = uri"/test") 27 | 28 | // when 29 | val response = 30 | Http4sCorrelationMiddleware(TestCorrelationIdDecorator).withCorrelationId(routes).apply(request).value.runSyncUnsafe().get 31 | 32 | //then 33 | response.status shouldBe Status.Ok 34 | 35 | seenCids.asScala.toList should have size (1) 36 | seenCids.asScala.toList.foreach(_ shouldBe Symbol("defined")) 37 | } 38 | 39 | it should "use correlation id from the header if available" in new Fixture { 40 | // given 41 | val testCid = "some-cid" 42 | val request = 43 | Request[Task](method = GET, uri = uri"/test", headers = Headers.of(Header(Http4sCorrelationMiddleware.HeaderName, testCid))) 44 | 45 | // when 46 | val response = 47 | Http4sCorrelationMiddleware(TestCorrelationIdDecorator).withCorrelationId(routes).apply(request).value.runSyncUnsafe().get 48 | 49 | //then 50 | response.status shouldBe Status.Ok 51 | 52 | seenCids.asScala.toList shouldBe List(Some(testCid)) 53 | } 54 | 55 | trait Fixture { 56 | val seenCids = new ConcurrentLinkedQueue[Option[String]]() 57 | 58 | val routes: HttpRoutes[Task] = HttpRoutes.of[Task] { case _ => 59 | TestCorrelationIdDecorator().asyncBoundary 60 | .flatMap(cid => Task.eval(seenCids.add(cid))) 61 | .flatMap(_ => Task.eval(logger.info("Hello!"))) 62 | .map(_ => Response[Task](Status.Ok)) 63 | } 64 | } 65 | } 66 | 67 | object TestCorrelationIdDecorator extends CorrelationIdDecorator() 68 | -------------------------------------------------------------------------------- /monix-logback/src/main/scala/com/softwaremill/correlator/CorrelationIdDecorator.scala: -------------------------------------------------------------------------------- 1 | package com.softwaremill.correlator 2 | 3 | import java.{util => ju} 4 | 5 | import ch.qos.logback.classic.util.LogbackMDCAdapter 6 | import com.softwaremill.correlator.CorrelationIdDecorator._ 7 | import monix.eval.Task 8 | import monix.execution.misc.Local 9 | import org.slf4j.{Logger, LoggerFactory, MDC} 10 | 11 | import scala.util.Random 12 | 13 | /** 14 | * Correlation id support. The `init()` method should be called when the application starts. 15 | * See [[https://blog.softwaremill.com/correlation-ids-in-scala-using-monix-3aa11783db81]] for details. 16 | */ 17 | class CorrelationIdDecorator(newCorrelationId: () => String = CorrelationIdDecorator.DefaultGenerator, mdcKey: String = "cid") { 18 | 19 | def init(): Unit = { 20 | MonixMDCAdapter.init() 21 | } 22 | 23 | def apply(): Task[Option[String]] = Task(Option(MDC.get(mdcKey))) 24 | 25 | def applySync(): Option[String] = Option(MDC.get(mdcKey)) 26 | 27 | def withCorrelationId[T, R](service: T => Task[R])(implicit source: CorrelationIdSource[T]): T => Task[R] = { req: T => 28 | val cid = source.extractCid(req).getOrElse(newCorrelationId()) 29 | 30 | val setupAndService = for { 31 | _ <- Task(MDC.put(mdcKey, cid)) 32 | r <- service(req) 33 | } yield r 34 | 35 | setupAndService.guarantee(Task(MDC.remove(mdcKey))) 36 | } 37 | } 38 | 39 | object CorrelationIdDecorator { 40 | val logger: Logger = LoggerFactory.getLogger(getClass.getName) 41 | 42 | private val random = new Random() 43 | 44 | val DefaultGenerator: () => String = { () => 45 | def randomUpperCaseChar() = (random.nextInt(91 - 65) + 65).toChar 46 | def segment = (1 to 3).map(_ => randomUpperCaseChar()).mkString 47 | s"$segment-$segment-$segment" 48 | } 49 | 50 | trait CorrelationIdSource[T] { 51 | def extractCid(t: T): Option[String] 52 | } 53 | } 54 | 55 | /** 56 | * Based on [[https://olegpy.com/better-logging-monix-1/]]. Makes the current correlation id available for logback 57 | * loggers. 58 | */ 59 | class MonixMDCAdapter extends LogbackMDCAdapter { 60 | private[this] val map = Local[ju.Map[String, String]](ju.Collections.emptyMap()) 61 | 62 | override def put(key: String, `val`: String): Unit = { 63 | if (map() eq ju.Collections.EMPTY_MAP) { 64 | map := new ju.HashMap() 65 | } 66 | map().put(key, `val`) 67 | () 68 | } 69 | 70 | override def get(key: String): String = map().get(key) 71 | override def remove(key: String): Unit = { 72 | map().remove(key) 73 | () 74 | } 75 | 76 | // Note: we're resetting the Local to default, not clearing the actual hashmap 77 | override def clear(): Unit = map.clear() 78 | override def getCopyOfContextMap: ju.Map[String, String] = new ju.HashMap(map()) 79 | override def setContextMap(contextMap: ju.Map[String, String]): Unit = 80 | map := new ju.HashMap(contextMap) 81 | 82 | override def getPropertyMap: ju.Map[String, String] = map() 83 | override def getKeys: ju.Set[String] = map().keySet() 84 | } 85 | 86 | object MonixMDCAdapter { 87 | def init(): Unit = { 88 | val field = classOf[MDC].getDeclaredField("mdcAdapter") 89 | field.setAccessible(true) 90 | field.set(null, new MonixMDCAdapter) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.4.7 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.softwaremill.sbt-softwaremill" % "sbt-softwaremill-common" % "1.9.15") 2 | addSbtPlugin("com.softwaremill.sbt-softwaremill" % "sbt-softwaremill-publish" % "1.9.15") 3 | -------------------------------------------------------------------------------- /scripts/decrypt_files_if_not_pr.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [[ "$TRAVIS_PULL_REQUEST" == "false" ]]; then 4 | openssl aes-256-cbc -K $encrypted_da1b9adf719b_key -iv $encrypted_da1b9adf719b_iv -in secrets.tar.enc -out secrets.tar -d 5 | tar xvf secrets.tar 6 | fi 7 | -------------------------------------------------------------------------------- /secrets.tar.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/softwaremill/correlator/21d89ff770bdffb0cb4f36fd328814f9f9910786/secrets.tar.enc -------------------------------------------------------------------------------- /version.sbt: -------------------------------------------------------------------------------- 1 | version in ThisBuild := "0.1.10-SNAPSHOT" 2 | --------------------------------------------------------------------------------