-
Notifications
You must be signed in to change notification settings - Fork 78
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Tracing resources #514
Comments
I tried adding |
You can put yourself in a dark place with this if you use allocated and don't release in reverse order. It's totally fine to create a child span from a finished span, but we lose access to the root span. I'm worried how fs2 rescoping might play here: test("weird and misnested") {
val tracer = new MockTracer
for {
root <- IO(MockSpan[IO](tracer, tracer.buildSpan("root").start()))
_ <- TraceResource.ioTrace(root).flatMap { trace =>
for {
a <- trace.resource("a").allocated
b <- trace.resource("b").allocated
_ <- a._2
// the ambient span is root
_ <- b._2
// the ambient span is a, which is finished
c <- trace.resource("c").use_
} yield ()
}
a <- lookupSpan(tracer, "a")
c <- lookupSpan(tracer, "c")
_ <- IO(root.span.finish())
_ = assertEquals(c.parentId, a.context.spanId)
} yield ()
} |
I am struggling with the def resource(name: String): Resource[Kleisli[F, Span[F], *], Unit] = {
val wtf = Kleisli((span: Span[F]) => span.span(name).void).mapF(_.pure[F]).map(_.mapK(Kleisli.liftK[F, Span[F]]))
Resource.suspend(wtf)
} I'm coming to appreciate the existing model more and more, but spanning a resource lifecycle seems pretty fundamental as well. |
I got the Kleisli instance working. I had to pass the resource as a continuation, similar to how we pass the effect as a continuation to
The same traced client now spans the entire lifecycle of the request, and works for both |
I somehow overlooked @kubukoz's prior art in #72 and #73. Jakub twote:
I don't think we have to introduce a second type class. My gist does, because I wanted a self-contained example. But I think the method could be added to A |
Just noticed this thread. Didn't have a chance to read through yet, but I've been wanting this too. I made an experimental lib a little while back exploring this idea: https://github.com/armanbilge/bayou/ I couldn't get the |
I think my Kleisli instance is more complicated than it needs to be, but it took several hours of type tetris to get the one in the gist. There's no Traverse here to save us from the resource being inside the Kleisli when we want it outside the Kleisli. |
Before putting up that lib, I remembering experimenting with a signature similar to what you have, in the hope that it could do def spanBracket[G[_]: FlatMap, A](f: Resource[F, *] ~> G)(name: String)(k: G[A]): G[A] I've got to run, but I remember getting tripped up with |
Btw, I think |
My unit test shows that the use is in the ambient span for |
Well I've stared at it for the last hour and am still wrapping my head around it. Took me this long just to figure out the type parameters on the def resource[A](name: String)(r: Resource[Kleisli[F, Span[F], *], A]): Resource[Kleisli[F, Span[F], *], A] =
Resource.suspend[Kleisli[F, Span[F], *], A](
Kleisli[Resource[F, *], Span[F], A]((span: Span[F]) =>
span.span(name).flatMap(child => r.mapK(Kleisli.applyK(child)))
).mapF[F, Resource[Kleisli[F, Span[F], *], A]](ra =>
ra.mapK[Kleisli[F, Span[F], *]](Kleisli.liftK[F, Span[F]]).pure[F]
)
) |
Btw, I've been kind of wishing for a natchez 0.2. Resource-tracing + |
I hadn't seen Bayou until you mentioned it, but you and @kubukoz and I all had the same basic idea. We all tried to return a I do agree that it's a party trick compared to |
Actually, I started with something similar to the latter idea as well :) I posted it on Discord but then couldn't figure out how to make it work for
Actually, I think that's quite a lot of value. Party-trick or not, it's only an implementation detail at the end of the day :) |
I overlooked your Unfortunately, my Kleisli instance doesn't work either. I had an error in my unit test, and the Kleisli returned by test("nested kleislis") {
val tracer = new MockTracer
for {
root <- IO(MockSpan[IO](tracer, tracer.buildSpan("root").start()))
trace = TraceResource[Kleisli[IO, Span[IO], *]]
_ <- trace.resource("a") {
trace.resource("b") {
Resource.unit
}
}.use(_ => trace.span("c")(Kleisli.pure(()))).run(root)
a <- lookupSpan(tracer, "a")
b <- lookupSpan(tracer, "b")
c <- lookupSpan(tracer, "c")
_ <- IO(root.span.finish())
_ = assertEquals(b.parentId, a.context.spanId)
// FAILS HERE
_ = assertEquals(c.parentId, b.context.spanId)
} yield ()
} We might be back to a |
Oh darn 😕 still, I think |
I got into |
Your I guess we could still have resourceful tracing on the transformers over |
If Although I've appreciated that
|
@zmccoy and I discussed wishing to be able to get the ambient |
Right, sorry. A true If you have direct access to the It's a compromise: this would be better than the current situation, where we can't span a |
Manually passing the span seems intrusive to me. We can't trace how we use a resource without changing the very shape of the resource. def helper[F[_]: MonadCancelThrow, A](span: Span[F], name: String)(r: Resource[F, A]): Resource[F, (Span[F], A)] = {
span.span(name).flatMap { case child =>
Resource(child.span("acquire").use(_ => r.allocated).map { case (a, release) =>
(child, a) -> child.span("release").use(_ => release)
})
}
}
def example[F[_]: MonadCancelThrow](span: Span[F]) = {
helper(span, "resource")(Resource.pure("precious")).use {
// Use went from `A => F[B]` to `(Span[F], A) => F[B]`
case (rSpan, precious) =>
rSpan.span("use").use_
}
} As soon as we trace a client, every call is going to have that extra value to deal with. Worse, that span won't bear any relation to the ambient span of |
So far, I haven't had the need for tracing The issue I think it surfaces more to me is tracing final class Routes[F[_]: GenUUID: Monad: Trace](
users: UsersDB[F]
) extends Http4sDsl[F]
trait UsersDB[F[_]]:
def get(id: UUID): F[Option[User]]
object UsersDB:
def make[F[_]: MonadThrow: Trace]: F[UsersDB[F]] = ??? To construct my Using I'm probably repeating myself here since I saw you both already mention the limitations of not being able to get a The same goes for consumer / producer examples, where I would like to resume a trace from a given kernel, can't be done it in an abstract Additionally, /* it uses `Trace.ioTrace` under the hood, so only for IO */
def ctxIOLocal(ep: EntryPoint[IO]) =
for
db <- Resource.eval(UsersDB.noTrace[IO])
routes = ep.liftRoutes(Routes[IO](db).routes)
server = Ember.routes[IO](port"9000", routes)
yield db -> server Linking it here in case it is helpful to anyone. |
@gvolpe Thanks for chiming in! Yes, I am acutely aware of this problem as a proponent of |
My trait TraceRoot[F[_]] {
def trace(root: Span[F]): F[Trace[F]]
}
object TraceRoot {
def apply[F[_]](implicit ev: TraceRoot[F]): ev.type = ev
implicit val traceRootForIO: TraceRoot[IO] =
(root: Span[IO]) => Trace.ioTrace(root)
} A |
Ha, that looks like another one of my ideas in #448. |
Is this suggesting that |
I think so, yes, unless you have a mechanism for injecting a new span on |
@rossabaker not the instance itself (that would be criminal), but the |
Yes, So a global |
Exactly as I understand it as well, thanks for confirming 👍🏽 I'm trying to understand by explaining the different ways of doing tracing with a few simple (but not as simple as the documentation) examples: https://github.com/gvolpe/trading/blob/main/modules/x-demo/src/main/scala/demo/tracer/TraceApp.scala Another alternative is to work with two different effect types, but not that pretty. def alt[F[_]: MonadThrow: Ref.Make, G[_]: MonadThrow: Trace](using NT[F, G]): F[UsersDB[G]] = ??? |
Avoiding that second type parameter may be why Abstracting over |
💯 that was a really nice summary.
Do you mean, it can't be shared beyond the scope of its "seed" span? The interesting thing is (unless I'm missing something) while we cannot fix |
Well, the problem as formulated here is effectively how to get from However, can we go def downgradeTrace[F[_]: MonadCancelThrow](resourceTrace: Trace[Resource[F, *]]): Trace[F] =
new Trace[F] {
def span[A](name: String)(fa: F[A]): F[A] =
resourceTrace.span(name)(Resource.eval(fa)).use(_.pure)
} This would suggest APIs should ask for the "strongest" |
I'm having trouble conceptualizing what that looks like. I haven not run this by scalac whatsoever: def traceClient[F[_]: MonadCancelThrow](client: Client[F])(impliciit T: Trace[Resource[F, *]]): Client[F] = { req: Request =>
T.span("client")(
Resource(
downgradeTrace(T).span("acquire")(client(req)).map { (resp, release) =>
resp -> downgrateTrace(T).span("release")(release)
}
)
}
} If my |
Er, crap. That thought came out half-baked if we're being generous. So sorry. What I think I was trying to get at was working in terms of Let me actually spend some quality time with |
Ok, I think I got where I intended to be this morning 😅 at least this time, scalac is backing me up. https://gist.github.com/armanbilge/3a2622d2d3834fe8c487ad74f2eb957a So far what we know is:
However, what I think/hope my gist shows is we can have effect that is both This might be obvious observation and not sure if it's any help in practice. |
Am I calling this right in your gist? I can't get it to compile, but I also haven't learned to read Scala 3 errors. I think I need a def traceClient[F[_]: MonadCancelThrow](client: Client[F])(implicit TR: Trace[Resource[F, *]]): Client[F] = { (req: Request) =>
implicit val T: Trace[F] = downgradeTrace[Resource[F, *], F]
TR.span("client")(
Resource(
T.span("acquire")(client(req)).map { (resp, release) =>
resp -> T.span("release")(release)
}
)
)
} |
Meanwhile, if we have a stateful trait TraceS[F[_]] extends Trace[F] {
def resource(name: String): Resource[F, Unit]
def withSpan(span: Span[F]): Resource[F, Unit]
} then this seems to work pretty well: test("app-wiring".only) {
trait Connection[F[_]]
trait Pool[F[_]] {
def borrow: Resource[F, Connection[F]]
}
object Pool {
def heavyAssPool[F[_]: MonadCancelThrow: TraceS]: Resource[F, Pool[F]] =
Resource.pure {
new Pool[F] {
def borrow = TraceS[F].resource("pool").as(new Connection[F] {})
}
}
}
trait UsersDB[F[_]] {
def findUser(id: Long): F[Option[String]]
}
object UsersDB {
def apply[F[_]: MonadCancelThrow: TraceS](pool: Pool[F]): UsersDB[F] =
new UsersDB[F] {
def findUser(id: Long): F[Option[String]] =
pool.borrow.use(_ => TraceS[F].span("db")(Applicative[F].pure(Some("Joe"))))
}
}
type Server[F[_]] = Request => Resource[F, Response]
object Server {
def apply[F[_]](f: Request => Resource[F, Response]): Server[F] = f
def trace[F[_]: MonadCancelThrow: TraceS](entryPoint: EntryPoint[F])(server: Server[F]): Server[F] = { req =>
entryPoint.root("server").flatMap(TraceS[F].withSpan) >>
Resource(
TraceS[F].span("acquire")(server(req).allocated).map { case (resp, release) =>
(resp, TraceS[F].span("release")(release))
}
)
}
}
def userServer[F[_]: Functor](usersDb: UsersDB[F]): Server[F] = {
case req =>
Resource.eval(usersDb.findUser(0L).map(_.fold(Response("oops"))(Response.apply)))
}
val entryPoint = new natchez.mock.MockEntrypoint[IO]()
(for {
root <- entryPoint.root("app")
srv <- Resource.eval(TraceS.ioTrace(root)).flatMap { implicit trace =>
for {
pool <- Pool.heavyAssPool[IO]
usersDb = UsersDB[IO](pool)
server = userServer[IO](usersDb)
} yield Server.trace(entryPoint)(server)
}
_ <- srv(Request("test"))
} yield root).use_ >> {
IO.println(entryPoint.mockTracer.finishedSpans())
}
} The |
Yes, you need to implement But what I was trying to get at is that we should avoid pre-binding our resource-effect to
Of course, if we buy into the stateful |
This is the issue most of the teams I work with encounter, and I have a solution to it which (after talking with @armanbilge), is more or less equivalent to I think fundamentally what we are proposing is changing the semantics of Trace from "the current trace" to "the ability to trace", i.e. current
|
I think there are three possible semantics to this. New @SystemFw dropped while I was writing this... trait TraceR[F[_]] extends Trace[F] {
def resource[A](name: String)(r: Resource[F, A]): Resource[F, A]
def entryPointTrace(name: String)(fa: F[A]):
}
object TraceR {
def apply[F[_]](implicit ev: TraceR[F]): ev.type = ev
}
def blah[F[_]: MonadCancelThrow: TraceR, A](r: Resource[F, A]): F[Unit] = {
TraceR[F].resource("resource")(
Resource(
TraceR[F].span("acquire")(r.allocated.map { case (a, release) =>
a -> TraceR[F].span("release")(release)
})
)
).use(_ => TraceR[F].span("use")(Applicative[F].unit))
} I have not tested this table but for all the experiments above, so call me on my bullshit:
Stateless I adds the ability to span a resource, but doesn't parent anything. It says acquisition, use, and release are caused by whoever uses it, but doesn't bundle them at all. Ideally we'd at least link to the resource, but we don't support linking yet. Stateless II says acquisition and release are caused by the resource, but use is caused by whoever uses it. This neatly matches the "lexical scope" of the trace calls, but still leaves no link between a resource and its use. Stateful seems like an extension of or complement to |
I'm still confused about this def entryPointTrace(name: String)(fa: F[A]): F[A] What does this do? How does it help me when I have a |
creates a new Span
It doesn't in the current form because it's a sketch, but you can imagine either adding |
Natchez is already integrated deeply into Skunk. There would be high upside to integrating it into the various backends of http4s. More effect libraries should instrument themselves. It will be unfortunate if we splinter libraries and end up with inconsistent constraints as we wire together apps. I'd love to see us rally around either the Stateless II or Stateful semantics above. I don't see a lot of upside to Stateless I, other than a simpler Resource signature than Stateless II. |
Question: is there a reason we can't expose all three new APIs? For example, according to your table Stateless I and Stateful actually expose the same APIs. So the change in behavior for acquire/release/use is completely due to how they are implemented. But is that a bad thing? This seems like a reasonable compromise: libraries can use the common |
Given: def foo[F[_]: Trace]: F[Unit] = ???
val rootSpan: Span[IO] = ???
def x = Trace.ioTrace(rootSpan).flatMap { implicit trace => foo[IO] }
def y = foo[Kleisli[IO, Span[IO], *].run(rootSpan)
I did not compile these examples, but I think they're close... *** Stateless I compatible trait Trace[IO] {
def resource(name: String): Resource[IO, Unit] =
for {
parent <- Resource.eval(local.get)
_ <- parent.span(name)
} yield ()
} *** Stateless II compatible trait Trace[IO] {
def resource[A](name: String)(r: Resource[IO, A]): Resource[IO, A] =
for {
parent <- Resource.eval(local.get)
child <- parent.span(name)
a <- Resource(local.set(child).bracket(_ =>
resource.allocated.map { case (a, release) =>
a -> local.set(child).bracket(_ => release)(_ => local.set(parent))
})(_ => local.set(parent)))
} yield a
} *** Stateful These are the only way the span of a resource is the ambient trace when using the resource, but this semantic can't be implemented with Local or Kleisli. trait Trace[IO] {
def resource(name: String): Resource[IO, Unit] =
for {
parent <- Resource.eval(local.get)
child <- parent.span(name)
_ <- Resource.make(local.set(child))(_ => local.set(parent))
} yield ()
// or
def resource[A](name: String)(r: Resource[IO, A]): Resource[IO, A] =
for {
parent <- Resource.eval(local.get)
child <- parent.span(name)
_ <- Resource.make(local.set(child))(_ => local.set(parent))
a <- r
} yield a
} |
Exactly. So my question is, why can't we have one type class, and no law? That seems a far better compromise and in practice a Edit: well, that's probably too extreme a stance, sorry. Looking harder at your snippet I guess that is something we could test, with a special law-testing span. def foo[F[_]: Trace]: F[Unit] = ???
val rootSpan: Span[IO] = ???
def x = Trace.ioTrace(rootSpan).flatMap { implicit trace => foo[IO] }
def y = foo[Kleisli[IO, Span[IO], *].run(rootSpan) |
For sake of argument, your law makes the no-op Is |
That's a smart point. I think it would be highly surprising that the IO and the Kleisli that produces the IO from the same span would behave differently, but the law can only be expressed with the operation that injects the root span, and only applies to instances that deal in spans. It's more good manners than law. |
As library authors working with Since the |
I started trying a PR, but the implicit def liftStateT[F[_]: Monad, S](implicit trace: Trace[F]): Trace[StateT[F, S, *]] = {
def resource[A](name: String)(r: Resource[StateT[F, S, *], A]): Resource[StateT[F, S, *], A] = ???
} I think we want to call Bayou has no problem because it just returns a |
I think it would be very helpful to have a draft PR if you have time, even if broken or incomplete, seeing the actual code would make further discussion easier for me |
Okay, my WIP is at #526, with some inline discussion. |
See also #527. |
Given this
Client
:We would like to able to span the entire resource, with child spans for acquisition and release:
Inspired by natchez-http4s and #19, we might try:
But the span tree looks like this:
We could introduce a more powerful
TraceResource
type class:Tracing a client becomes:
Executable proof of concept:
$ scala-cli test -S 2.13 https://gist.github.com/rossabaker/8872792b06bd84e5be8fae3c9caf8731
We could avoid the second type class by adding the method to
Trace
. This would be a breaking change. In addition to the new abstract method, we'd have tomapK
theResource
on all the transformed instances, which would require aMonadCancel
where there are currently weaker constraints. All the base instances exceptnoop
already requireMonadCancel
, so maybe that's okay./cc @zmccoy
The text was updated successfully, but these errors were encountered: