Closed tbrown1979 closed 1 year ago
Hey! I'd definitely like to do this but I'm not quite sure what the end-user API is going to look like yet so things may be changing underneath you. If you're ok with that I would welcome an http4s module.
I'm curious, which back end are you going to use? I have been focusing on Honeycomb.
@tpolecat I totally understand. To be honest, I had started something very similar to this project internally at my company but didn't have the time to fully flesh it out. I was essentially going to port the http4s stuff I had created over to this. It's not a lot of code so any changes shouldn't be too rough :)
I'd most likely be using Jaeger as my backend, which I see already has a module!
Ok I'll see if I can get a handle on the API this week. I'll open PRs for everything so we can discuss changes. Thanks for your interest!
I'm very interested in this, mainly from the PoV that when using the Klieisli tracer there is no derived instance for ConcurrentEffect
available, and I'm not sure I want to get into handcrafting one inside an EntryPoint
closure.
Do either of you have a different method for injecting a Trace
which will work when a ConcurrentEffect
instance is required?
I'm going to answer my own question here in case anyone is wondering about the same thing:
When constructing routes or a http app where F
is a Kleisli, in this case Kleisli[F, Span[F], ?]
, it is possible to call translate
and pass natural transformations from F
to Kleisli[F, Span[F], ?]
and from Kleisli[F, Span[F], ?]
to F
. See the http4s syntax here.
The natural transformations will have to be constructed inside the EntryPoint
closure, but it will mean that the routes will be usable by a Http4s server, without having to create a type class instance of ConcurrentEffect
for Kleisli[F, Span[F], ?]
.
Using a bastardised version of the example module, this is what this method would look like:
import org.http4s.syntax.all
...
val fk = new (F ~> Kleisli[F, Span[F], ?]) {
def apply(fa: F[A]): Kleisli[F, Span[F], ?] = Kleisli.liftF(fa)
}
entryPoint[F].use { ep =>
ep.root("root").use { span =>
val gk = new (Kleisli[F, Span[F], ?] ~> F) {
def apply(fa: Kleisli[F, Span[F], ?]): F[A] = fa.run(span)
}
val routes: HttpRoutes[Kleisli[F, Span[F], ?]] = ???
val transformedRoutes: HttpRoutes[F] = routes.transform(gk)(fk)
}
}
Once the API is stabilised I could create a proper example of this if people are interested.
Hi @janstenpickle. I have a concern regarding your example. Root span is being created during the start of the application and then it passed to every request. As a result, only child route can be created inside a route handler. Thus in Jaeger will be only one big trace with tons of sub-traces.
I've implemented a middleware that creates a new span per request:
Personally, I don't like this approach for several reasons:
1) natchez.Trace
typeclass is ignored and all child spans are being created directly through the Span[F]
;
2) Ugly routes syntax;
In another project, I've been using a different solution based on ApplicativeHandle from Cats MTL. I've described span as an ADT and it was a part of the effect.
This one much more flexible and can be easily integrated with Http4s. Is there is another simple way to create a root span inside the application?
WDYT @tpolecat ?
Thanks for your comments, I will try to have a look soon but I'm super busy today.
Thanks @tpolecat, no rush!
@iRevive you're quite right, thanks for the correction! I actually did a similar thing to your first example but I forgot to update this issue. I really like your MTL approach!
@janstenpickle thanks!
The obvious downside of the MTL approach that it requires an additional dependency.
I figured out that Trace
can be described without ApplicativeHandle.
This implementation has two differences from the natchez library: 1) Trace can create a root span; 2) Span represented as ADT: Empty and Defined.
The downside of ADT is that you cannot guarantee at the compile time that root span is already created. For example, Trace[F].childSpan("child")(fa)
will not have any effect if root span wasn't created at the bottom of the call stack.
Here is what I came up with. This lets you write your routes with a Trace
constraint and then lift them into your normal F
. The interesting bit is in def app
where we say ep.liftT
to lift our Trace
-demanding routes into F
, which doesn't have a Trace
instance. This works by inferring Kleisli[F, Span[F], ?]
as the type argument when we call routes
. Slightly sneaky.
import cats.effect._
import cats.implicits._
import io.jaegertracing.Configuration._
import natchez._
import natchez.http4s.implicits._
import natchez.jaeger.Jaeger
import org.http4s._
import org.http4s.dsl.Http4sDsl
import org.http4s.implicits._
import org.http4s.server._
import org.http4s.server.blaze.BlazeServerBuilder
object Http4sExample extends IOApp {
// This is what we want to write: routes in F[_]: ...: Trace
def routes[F[_]: Sync: Trace]: HttpRoutes[F] = {
object dsl extends Http4sDsl[F]; import dsl._
HttpRoutes.of[F] {
case GET -> Root / "hello" / name =>
Trace[F].put("woot" -> 42) *>
Trace[F].span("responding") {
Ok(s"Hello, $name.")
}
}
}
// Normal constructor for an HttpApp in F *without* a Trace constraint.
def app[F[_]: Sync: Bracket[?[_], Throwable]](ep: EntryPoint[F]): HttpApp[F] =
Router("/" -> ep.liftT(routes)).orNotFound // <-- Lifted routes
// Normal server resource
def server[F[_]: ConcurrentEffect: Timer](routes: HttpApp[F]): Resource[F, Server[F]] =
BlazeServerBuilder[F]
.bindHttp(8080, "localhost")
.withHttpApp(routes)
.resource
// Normal EntryPoint resource
def entryPoint[F[_]: Sync]: Resource[F, EntryPoint[F]] =
Jaeger.entryPoint[F]("natchez-example") { c =>
Sync[F].delay {
c.withSampler(SamplerConfiguration.fromEnv)
.withReporter(ReporterConfiguration.fromEnv)
.getTracer
}
}
// Main method instantiates F to IO
def run(args: List[String]): IO[ExitCode] =
entryPoint[IO].map(app(_)).flatMap(server(_)).use(_ => IO.never).as(ExitCode.Success)
}
The implementation is a little janky, may be able to simplify. But it works.
package natchez.http4s
import cats.~>
import cats.data.{ Kleisli, OptionT }
import cats.effect.Bracket
import natchez.{ EntryPoint, Kernel, Span }
import org.http4s.HttpRoutes
object implicits {
// Given an entry point and HTTP Routes in Kleisli[F, Span[F], ?] return routes in F. A new span
// is created with the URI path as the name, either as a continuation of the incoming trace, if
// any, or as a new root. This can likely be simplified, I just did what the types were saying
// and it works so :shrug:
private def liftT[F[_]: Bracket[?[_], Throwable]](
entryPoint: EntryPoint[F])(
routes: HttpRoutes[Kleisli[F, Span[F], ?]]
): HttpRoutes[F] =
Kleisli { req =>
type G[A] = Kleisli[F, Span[F], A]
val lift = λ[F ~> G](fa => Kleisli(_ => fa))
val kernel = Kernel(req.headers.toList.map(h => (h.name.value -> h.value)).toMap)
val spanR = entryPoint.continueOrElseRoot(req.uri.path, kernel)
OptionT {
spanR.use { span =>
val lower = λ[G ~> F](_(span))
routes.run(req.mapK(lift)).mapK(lower).map(_.mapK(lower)).value
}
}
}
implicit class EntryPointOps[F[_]](self: EntryPoint[F]) {
def liftT(routes: HttpRoutes[Kleisli[F, Span[F], ?]])(
implicit ev: Bracket[F, Throwable]
): HttpRoutes[F] =
implicits.liftT(self)(routes)
}
}
Hitting the endpoint yields a trace like
I think this provides exactly what I want. WDYT?
@tpolecat LGTM.
This approach covers all my use cases.
One little thing. I would like to have a bit more control over headers passed to Kernel:
val kernel = Kernel(req.headers.toList.map(h => (h.name.value -> h.value)).toMap)
.
req.headers
can expose an authentication credentials/api token/etc.
The Logger middleware from Http4s provides a way to redact headers:
def httpRoutes[F[_]: Concurrent](
logHeaders: Boolean,
logBody: Boolean,
redactHeadersWhen: CaseInsensitiveString => Boolean = Headers.SensitiveHeaders.contains,
logAction: Option[String => F[Unit]] = None
)(httpRoutes: HttpRoutes[F]): HttpRoutes[F] =
Is this applicable here?
@tpolecat how would you handle situation when routes
depends on ConcurrentEffect
since there is no Effect
for Kleisli
?
You're out of luck in that case, although you could pass the span explicitly.
ConcurrentEffect
is a ridiculously tight constraint so hopefully it's not needed much. One known exception is the http4s Blaze client. If you're in this situation you might try the Ember client.
Blaze client is my case actually :) other place that I found static files/webjar service in Htt4s.
I will try to pass Span explicitly for the time being.
@tpolecat @pshemass I'm facing the Effect
problem for a service dependency that doesn't even need tracing. Could you give an example of providing the span explicitly?
Ended up with this:
def kleisliConcurrentEffect[F[_]](
root: Span[F]
)(implicit F: Concurrent[Kleisli[F, Span[F], *]], FF: ConcurrentEffect[F]): ConcurrentEffect[Kleisli[F, Span[F], *]] =
new ConcurrentEffect[Kleisli[F, Span[F], *]] {
override def runCancelable[A](fa: Kleisli[F, Span[F], A])(cb: Either[Throwable, A] => IO[Unit]): SyncIO[Kleisli[F, Span[F], Unit]] =
FF.runCancelable(fa.run(root))(cb).map(Kleisli.liftF)
override def runAsync[A](fa: Kleisli[F, Span[F], A])(cb: Either[Throwable, A] => IO[Unit]): SyncIO[Unit] =
FF.runAsync(fa.run(root))(cb)
// All other implementation from Concurrent[Kleisli[F, Span[F], *]]
}
implicit val a = kleisliConcurrentEffect[IO](NoopSpan[IO]()) // since this is for a dependency that doesn't need tracing
Hey @tpolecat would you be open to a PR adding an http4s module for this project? Wrapping a
Client
, middleware for a server, maybe other things? If so, I'll try to get something created soonish