Natchez HTTP4s

natchez-extras-http4s provides HTTP4s Middleware to trace all HTTP requests. At the time of writing there is a PR on Natchez itself that will provide this functionality. When it is merged this module will continue to exist but as a wrapper that adds tags used by Datadog.

Installation

val natchezExtrasVersion = "8.1.0"

libraryDependencies ++= Seq(
  "com.ovoenergy" %% "natchez-extras-http4s-stable" % natchezExtrasVersion
)

Usage

These examples assume you’ve installed the following extra dependencies:

val http4sVersion = "0.23.23"

libraryDependencies ++= Seq(
  "org.http4s"    %% "http4s-blaze-server"   % http4sVersion,
  "org.http4s"    %% "http4s-blaze-client"   % http4sVersion
)

To use Natchez HTTP4s you create an HttpApp[Kleisli[F, Span[F], *]] (i.e. an HttpApp that requires a span to run) and pass it into TraceMiddleware to obtain an HttpApp[F] you can then run normally.

import cats.data.Kleisli
import cats.effect._
import cats.syntax.flatMap._
import cats.syntax.functor._
import com.ovoenergy.natchez.extras.datadog.Datadog
import com.ovoenergy.natchez.extras.http4s.Configuration
import com.ovoenergy.natchez.extras.http4s.server.TraceMiddleware
import natchez.{EntryPoint, Span, Trace}
import org.http4s.blaze.client.BlazeClientBuilder
import org.http4s.dsl.Http4sDsl
import org.http4s.blaze.server.BlazeServerBuilder
import org.http4s.{HttpApp, HttpRoutes}

import scala.concurrent.duration._

object NatchezHttp4s extends IOApp {

  /**
   * An example API with a simple GET endpoint
   * and a POST endpoint that does a few sub operations
   */
  def createRoutes[F[_]: Trace: Temporal]: HttpRoutes[F] = {
    val dsl = Http4sDsl[F]
    import dsl._
    HttpRoutes.of {
      case GET -> Root =>
        Ok("Well done")
      case POST -> Root =>
        for {
          _ <- Trace[F].span("operation-1")(Temporal[F].sleep(10.millis))
          _ <- Trace[F].span("operation-2")(Temporal[F].sleep(50.millis))
          res <- Created("Thanks")
        } yield res
    }
  }

  /**
   * Create a Natchez entrypoint that will send traces to Datadog
   */
  val datadog: Resource[IO, EntryPoint[IO]] =
    for {
      httpClient <- BlazeClientBuilder[IO].withDefaultSslContext.resource
      entryPoint <- Datadog.entryPoint(httpClient, "example-http-api", "default-resource")
    } yield entryPoint


  def run(args: List[String]): IO[ExitCode] =
    datadog.use { entryPoint =>

      /**
       * Our routes need a Trace instance to create spans etc
       * and the only type that has a trace instance is a Kleisli
       */
      type TracedIO[A] = Kleisli[IO, Span[IO], A]
      val tracedRoutes: HttpApp[TracedIO] = createRoutes[TracedIO].orNotFound

      /**
       * We then apply the TraceMiddleware to the routes to obtain an `HttpApp[IO]`.
       * The middleware will create traces for each incoming request.
       */
      val routes: HttpApp[IO] =
        TraceMiddleware[IO](entryPoint, Configuration.default())(tracedRoutes)

      /**
       * We can then serve the routes as normal
       */
      BlazeServerBuilder[IO]
        .bindHttp(8080, "0.0.0.0")
        .withHttpApp(routes)
        .withoutBanner
        .serve
        .compile
        .lastOrError
    }
}

Running the above app and hitting the POST endpoint should generate a trace like this:

datadog trace

Tracing only some routes

Often you don’t want to trace all of your routes, for example if you have a healthcheck route that is polled by a load balancer every few seconds you may wish to exclude it from your traces.

You can do this using .fallthroughTo provided in the syntax package which allows the combination of un-traced HttpRoutes[F] and the HttpApp[F] that the tracing middleware returns:

import cats.data.Kleisli
import cats.effect.{ExitCode, IO, IOApp, Resource}
import com.ovoenergy.natchez.extras.datadog.Datadog
import com.ovoenergy.natchez.extras.http4s.Configuration
import com.ovoenergy.natchez.extras.http4s.server.TraceMiddleware
import com.ovoenergy.natchez.extras.http4s.server.syntax.KleisliSyntax
import natchez.{EntryPoint, Span}
import org.http4s._
import org.http4s.blaze.client.BlazeClientBuilder
import org.http4s.dsl.io._
import org.http4s.blaze.server.BlazeServerBuilder

object Main extends IOApp {
  
  type TraceIO[A] = Kleisli[IO, Span[IO], A]
  val conf: Configuration[IO] = Configuration.default()

  val datadog: Resource[IO, EntryPoint[IO]] =
    for {
      httpClient <- BlazeClientBuilder[IO].withDefaultSslContext.resource
      entryPoint <- Datadog.entryPoint(httpClient, "example-http-api", "default-resource")
    } yield entryPoint

  val healthcheck: HttpRoutes[IO] =
    HttpRoutes.of { case GET -> Root / "health" => Ok("healthy") }
  
  val application: HttpRoutes[TraceIO] = 
    HttpRoutes.pure(Response(status = Status.InternalServerError))
   
  def run(args: List[String]): IO[ExitCode] =
    datadog.use { entryPoint =>

      val combinedRoutes: HttpApp[IO] =
        healthcheck.fallthroughTo(TraceMiddleware(entryPoint, conf)(application.orNotFound))
    
      BlazeServerBuilder[IO]
        .withHttpApp(combinedRoutes)
        .bindHttp(port = 8080)
        .serve
        .compile
        .lastOrError
    }
}

Configuration

Given that every HTTP API is likely to have different tracing requirements natchez-http4s attempts to be as configurable as possible. The Configuration object passed to TraceMiddleware defines how to turn an HTTP requests and responses into Natchez tags. By default it is set up to create tags suitable for Datadog but you can use the helper functions in Configuration to create your own configs:

import cats.effect.IO
import com.ovoenergy.natchez.extras.http4s.Configuration
import com.ovoenergy.natchez.extras.http4s.Configuration.TagReader._
import natchez.TraceValue.BooleanValue
import cats.syntax.semigroup._

object CustomConfigExample {

  /**
   * Describe what we want to read from request and put as tags into the span.
   * This configuration only adds the url and the method. You can use `|+|` to combine
   * together configurations.
   */
  val customRequestConfig: RequestReader[IO] =
    Configuration.uri[IO]("http_request_url") |+|
    Configuration.method[IO]("http_method")

  /**
   * Describe what to read from the HTTP response generated by the app and put into tags.
   * This configuration won't read anything but will put failed: true if the response is not a 2xx
   */
  val customResponseConfig: ResponseReader[IO] =
    Configuration.ifFailure(Configuration.const("failed", BooleanValue(true)))

  /**
   * The request & response configurations are combined together into this case class
   * which can then be passed to `TraceMiddleware`
   */
  val customConfig: Configuration[IO] =
    Configuration(
      request = customRequestConfig,
      response = customResponseConfig
    )
}