Skip to content
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

An exploration of cats.mtl.Local tracing #102

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ ThisBuild / scalaVersion := Scala213 // the default Scala

val CatsVersion = "2.9.0"
val CatsEffectVersion = "3.4.5"
val CatsMtlVersion = "1.3.0"
val FS2Version = "3.5.0"
val MUnitVersion = "1.0.0-M7"
val MUnitCatsEffectVersion = "2.0.0-M3"
Expand Down Expand Up @@ -96,6 +97,7 @@ lazy val `core-trace` = crossProject(JVMPlatform, JSPlatform, NativePlatform)
name := "otel4s-core-trace",
libraryDependencies ++= Seq(
"org.typelevel" %%% "cats-effect-kernel" % CatsEffectVersion,
"org.typelevel" %%% "cats-mtl" % CatsMtlVersion,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Negative: another core dependency in a library we hope to integrate with the flagships.

"org.scodec" %%% "scodec-bits" % ScodecVersion
)
)
Expand Down Expand Up @@ -175,7 +177,7 @@ lazy val `java-trace` = project
.settings(
name := "otel4s-java-trace",
libraryDependencies ++= Seq(
"org.typelevel" %%% "cats-effect" % CatsEffectVersion,
"org.typelevel" %%% "cats-mtl" % CatsMtlVersion,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bonus: the hard IO dependency is gone.

"io.opentelemetry" % "opentelemetry-sdk-testing" % OpenTelemetryVersion % Test,
"org.typelevel" %%% "cats-effect-testkit" % CatsEffectVersion % Test,
"co.fs2" %% "fs2-core" % FS2Version % Test
Expand All @@ -196,6 +198,7 @@ lazy val examples = project
.settings(
name := "otel4s-examples",
libraryDependencies ++= Seq(
"org.typelevel" %%% "cats-effect" % CatsEffectVersion,
"io.opentelemetry" % "opentelemetry-exporter-otlp" % OpenTelemetryVersion,
"io.opentelemetry" % "opentelemetry-sdk" % OpenTelemetryVersion,
"io.opentelemetry" % "opentelemetry-sdk-extension-autoconfigure" % s"${OpenTelemetryVersion}-alpha"
Expand Down
37 changes: 32 additions & 5 deletions examples/src/main/scala/TracingExample.scala
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@
* limitations under the License.
*/

import cats.Applicative
import cats.effect.IO
import cats.effect.IOApp
import cats.effect.IOLocal
import cats.effect.MonadCancelThrow
import cats.effect.Resource
import cats.effect.std.Console
import cats.mtl.Local
import cats.syntax.all._
import io.opentelemetry.api.GlobalOpenTelemetry
import org.typelevel.otel4s.java.OtelJava
Expand All @@ -44,11 +47,35 @@ object Work {
}

object TracingExample extends IOApp.Simple {
def tracerResource: Resource[IO, Tracer[IO]] =
Resource
.eval(IO(GlobalOpenTelemetry.get))
.evalMap(OtelJava.forSync[IO])
.evalMap(_.tracerProvider.tracer("Example").get)

// This would be a library function
def local[E](a: E): IO[Local[IO, E]] =
IOLocal(a).map { ioLocal =>
new Local[IO, E] {
override def local[A](fa: IO[A])(f: E => E): IO[A] =
ioLocal.get.flatMap(prev =>
ioLocal
.set(f(prev))
.bracket(Function.const(fa))(Function.const(ioLocal.set(prev)))
)
override val applicative: Applicative[IO] =
Applicative[IO]
override def ask[E2 >: E]: IO[E2] =
ioLocal.get
}
}
Comment on lines +51 to +66
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is my interpretation of typelevel/cats-effect#3385, but I haven't tested it.


def tracerResource: Resource[IO, Tracer[IO]] = {
import io.opentelemetry.context.{Context => JContext}
import org.typelevel.otel4s.java.trace.TraceScope.Scope
Comment on lines +69 to +70
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neither of these imports are particularly desirable in userspace.

Resource.eval(local(Scope.Root(JContext.root()): Scope)).flatMap {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And this is worse. If we're going to expose Scope, we should have a clean way for users to construct this one.

implicit local: Local[IO, Scope] =>
Resource
.eval(IO(GlobalOpenTelemetry.get))
.evalMap(OtelJava.forSync[IO])
.evalMap(_.tracerProvider.tracer("Example").get)
}
}

def run: IO[Unit] = {
tracerResource.use { implicit tracer: Tracer[IO] =>
Expand Down
18 changes: 10 additions & 8 deletions java/all/src/main/scala/org/typelevel/otel4s/java/OtelJava.scala
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,18 @@

package org.typelevel.otel4s.java

import cats.effect.Async
import cats.effect.LiftIO
import cats.effect.Resource
import cats.effect.Sync
import cats.syntax.flatMap._
import cats.effect.kernel.Async
import cats.effect.kernel.Resource
import cats.effect.kernel.Sync
import cats.mtl.Local
import cats.syntax.functor._
import io.opentelemetry.api.{OpenTelemetry => JOpenTelemetry}
import io.opentelemetry.sdk.{OpenTelemetrySdk => JOpenTelemetrySdk}
import io.opentelemetry.sdk.common.CompletableResultCode
import org.typelevel.otel4s.Otel4s
import org.typelevel.otel4s.java.Conversions.asyncFromCompletableResultCode
import org.typelevel.otel4s.java.metrics.Metrics
import org.typelevel.otel4s.java.trace.TraceScope
import org.typelevel.otel4s.java.trace.Traces
import org.typelevel.otel4s.metrics.MeterProvider
import org.typelevel.otel4s.trace.TracerProvider
Expand All @@ -46,10 +46,12 @@ object OtelJava {
* @return
* An effect of an [[org.typelevel.otel4s.Otel4s]] resource.
*/
def forSync[F[_]: LiftIO: Sync](jOtel: JOpenTelemetry): F[Otel4s[F]] = {
def forSync[F[_]: Sync: Local[*[_], TraceScope.Scope]](
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where Scope starts to leak. I think we can fix this with some Greek letters.

[edit] Well, probably not: if F is Kleisli, we start to publicly return a bunch of functions of Scope.

jOtel: JOpenTelemetry
): F[Otel4s[F]] = {
for {
metrics <- Sync[F].pure(Metrics.forSync(jOtel))
traces <- Traces.ioLocal(jOtel)
traces = Traces.local(jOtel)
} yield new Otel4s[F] {
def meterProvider: MeterProvider[F] = metrics.meterProvider
def tracerProvider: TracerProvider[F] = traces.tracerProvider
Expand All @@ -64,7 +66,7 @@ object OtelJava {
* @return
* An [[org.typelevel.otel4s.Otel4s]] resource.
*/
def resource[F[_]: LiftIO: Async](
def resource[F[_]: Async: Local[*[_], TraceScope.Scope]](
acquire: F[JOpenTelemetrySdk]
): Resource[F, Otel4s[F]] =
Resource
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ package org.typelevel.otel4s
package java
package trace

import cats.effect.Sync
import cats.effect.kernel.Sync
import io.opentelemetry.api.trace.{Span => JSpan}
import io.opentelemetry.api.trace.{StatusCode => JStatusCode}
import org.typelevel.otel4s.Attribute
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
package org.typelevel.otel4s.java
package trace

import cats.effect.Resource
import cats.effect.Sync
import cats.effect.kernel.Resource
import cats.effect.kernel.Sync
import cats.syntax.flatMap._
import cats.syntax.foldable._
import cats.syntax.functor._
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,9 @@

package org.typelevel.otel4s.java.trace

import cats.effect.IOLocal
import cats.effect.LiftIO
import cats.effect.Resource
import cats.effect.Sync
import cats.syntax.functor._
import cats.effect.kernel.Resource
import cats.effect.kernel.Sync
import cats.mtl.Local
import io.opentelemetry.api.trace.{Span => JSpan}
import io.opentelemetry.context.{Context => JContext}
import org.typelevel.otel4s.trace.SpanContext
Expand All @@ -34,7 +32,7 @@ private[java] trait TraceScope[F[_]] {
def noopScope: Resource[F, Unit]
}

private[java] object TraceScope {
object TraceScope {

sealed trait Scope
object Scope {
Expand All @@ -47,67 +45,70 @@ private[java] object TraceScope {
case object Noop extends Scope
}

def fromIOLocal[F[_]: LiftIO: Sync](
private[java] def fromLocal[F[_]: Sync: Local[*[_], Scope]](
default: JContext
): F[TraceScope[F]] = {
): TraceScope[F] = {
val scopeRoot = Scope.Root(default)

IOLocal[Scope](scopeRoot).to[F].map { local =>
new TraceScope[F] {
val root: F[Scope.Root] =
Sync[F].pure(scopeRoot)
new TraceScope[F] {
val root: F[Scope.Root] =
Sync[F].pure(scopeRoot)

def current: F[Scope] =
local.get.to[F]
def current: F[Scope] =
Local[F, Scope].ask[Scope]

def makeScope(span: JSpan): Resource[F, Unit] =
for {
current <- Resource.eval(current)
_ <- createScope(nextScope(current, span))
} yield ()
def makeScope(span: JSpan): Resource[F, Unit] =
for {
current <- Resource.eval(current)
_ <- createScope(nextScope(current, span))
} yield ()

def rootScope: Resource[F, Unit] =
Resource.eval(current).flatMap {
case Scope.Root(_) =>
createScope(scopeRoot)
def rootScope: Resource[F, Unit] =
Resource.eval(current).flatMap {
case Scope.Root(_) =>
createScope(scopeRoot)

case Scope.Span(_, _, _) =>
createScope(scopeRoot)
case Scope.Span(_, _, _) =>
createScope(scopeRoot)

case Scope.Noop =>
createScope(Scope.Noop)
}
case Scope.Noop =>
createScope(Scope.Noop)
}

def noopScope: Resource[F, Unit] =
createScope(Scope.Noop)
def noopScope: Resource[F, Unit] =
createScope(Scope.Noop)

private def createScope(scope: Scope): Resource[F, Unit] =
private def createScope(scope: Scope): Resource[F, Unit] = {
val _ = scope
// There's no getAndSet. This is inherently stateful.
/*
Resource
.make(local.getAndSet(scope).to[F])(p => local.set(p).to[F])
.void
*/
Comment on lines +83 to +88
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And this here is where the wheels fall off. Do we want tracing of more than IOLocal, or do we want acquire, use, and release to be siblings? This was a matter of long debate in Natchez.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there's a case for both. If the three phases are part of the same trace, then you'd want them to be siblings, but if they should be part of different traces, then not.

Motivation for all three in the same trace: the Resource[F, Response[F]] from http4s's Client.run, where the whole thing should be encapsulated in the trace that was responsible for starting the request.

Motivation for separate traces: a Resource[F, A] where A is a long-lived server process, where IMHO ideally you'd have an initialization trace that completes when the acquisition phase is complete and a finalization trace that starts and finishes when the resource is finalized. (In my head, the use is not really traced itself, but each request handled by the server would start a new trace, possibly linked to the acquisition trace if that would be helpful.)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exposing the Resource as Client#run really shot us in the foot for this use case. We used to accept the callback directly -- I think it was called fetch. There's not a whole lot we can do with local semantics:

  1. We can span whatever calls Client#run, but Client#run can't span it locally because it can't see it.
  2. We could have a Resource[F, A] => Resource[F, (F ~> F, A)], and the user of the resource would be responsible for calling the natural transformation for scoping. But that's roughly as intrusive as tracing the caller of Client#run and more confusing.
  3. We could create a new resource-like algebra with an Intercept, constructable from Resource. But, gosh, that's disruptive.
  4. Someone smarter than me could figure out how to shoehorn it into the existing constructors. I more readily believe someone is smarter than me than that this is possible.

Resource.unit[F]
}

private def nextScope(scope: Scope, span: JSpan): Scope =
scope match {
case Scope.Root(ctx) =>
Scope.Span(
ctx.`with`(span),
span,
WrappedSpanContext(span.getSpanContext)
)

case Scope.Span(ctx, _, _) =>
Scope.Span(
ctx.`with`(span),
span,
WrappedSpanContext(span.getSpanContext)
)

case Scope.Noop =>
Scope.Noop
}
private def nextScope(scope: Scope, span: JSpan): Scope =
scope match {
case Scope.Root(ctx) =>
Scope.Span(
ctx.`with`(span),
span,
WrappedSpanContext(span.getSpanContext)
)

case Scope.Span(ctx, _, _) =>
Scope.Span(
ctx.`with`(span),
span,
WrappedSpanContext(span.getSpanContext)
)

case Scope.Noop =>
Scope.Noop
}

}
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

package org.typelevel.otel4s.java.trace

import cats.effect.Sync
import cats.effect.kernel.Sync
import io.opentelemetry.api.trace.{TracerProvider => JTracerProvider}
import org.typelevel.otel4s.trace._

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@

package org.typelevel.otel4s.java.trace

import cats.effect.Resource
import cats.effect.Sync
import cats.effect.kernel.Resource
import cats.effect.kernel.Sync
import cats.syntax.functor._
import io.opentelemetry.api.trace.{Span => JSpan}
import io.opentelemetry.api.trace.{Tracer => JTracer}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,8 @@

package org.typelevel.otel4s.java.trace

import cats.effect.LiftIO
import cats.effect.Sync
import cats.syntax.functor._
import cats.effect.kernel.Sync
import cats.mtl.Local
import io.opentelemetry.api.trace.{TracerProvider => JTracerProvider}
import io.opentelemetry.context.{Context => JContext}
import org.typelevel.otel4s.trace.TracerBuilder
Expand All @@ -34,12 +33,12 @@ private[java] class TracerProviderImpl[F[_]: Sync](

private[java] object TracerProviderImpl {

def ioLocal[F[_]: LiftIO: Sync](
def local[F[_]: Sync: Local[*[_], TraceScope.Scope]](
jTracerProvider: JTracerProvider,
default: JContext = JContext.root()
): F[TracerProvider[F]] =
TraceScope
.fromIOLocal[F](default)
.map(scope => new TracerProviderImpl(jTracerProvider, scope))
): TracerProvider[F] = {
val scope = TraceScope.fromLocal[F](default)
new TracerProviderImpl(jTracerProvider, scope)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,8 @@

package org.typelevel.otel4s.java.trace

import cats.effect.LiftIO
import cats.effect.Sync
import cats.syntax.functor._
import cats.effect.kernel.Sync
import cats.mtl.Local
import io.opentelemetry.api.{OpenTelemetry => JOpenTelemetry}
import org.typelevel.otel4s.trace.TracerProvider

Expand All @@ -28,11 +27,13 @@ trait Traces[F[_]] {

object Traces {

def ioLocal[F[_]: LiftIO: Sync](jOtel: JOpenTelemetry): F[Traces[F]] =
for {
provider <- TracerProviderImpl.ioLocal(jOtel.getTracerProvider)
} yield new Traces[F] {
def local[F[_]: Sync: Local[*[_], TraceScope.Scope]](
jOtel: JOpenTelemetry
): Traces[F] = {
val provider = TracerProviderImpl.local(jOtel.getTracerProvider)
new Traces[F] {
def tracerProvider: TracerProvider[F] = provider
}
}

}