diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14b53176b..4113c48f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,11 +84,11 @@ jobs: - name: Make target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') - run: mkdir -p testkit/metrics/jvm/target java/metrics/target testkit/common/jvm/target core/trace/.js/target semconv/.jvm/target core/common/.jvm/target java/trace/target unidocs/target core/metrics/.native/target core/all/.native/target core/metrics/.jvm/target core/all/.js/target java/all/target java/common/target core/metrics/.js/target core/all/.jvm/target core/trace/.native/target semconv/.js/target core/trace/.jvm/target core/common/.native/target core/common/.js/target semconv/.native/target testkit/all/jvm/target project/target + run: mkdir -p testkit/metrics/jvm/target java/metrics/target testkit/common/jvm/target core/trace/.js/target semconv/.jvm/target core/common/.jvm/target java/trace/target unidocs/target core/metrics/.native/target core/all/.native/target core/metrics/.jvm/target java/context-storage/target core/all/.js/target java/all/target java/common/target core/metrics/.js/target core/all/.jvm/target core/trace/.native/target semconv/.js/target core/trace/.jvm/target core/common/.native/target core/common/.js/target semconv/.native/target testkit/all/jvm/target project/target - name: Compress target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') - run: tar cf targets.tar testkit/metrics/jvm/target java/metrics/target testkit/common/jvm/target core/trace/.js/target semconv/.jvm/target core/common/.jvm/target java/trace/target unidocs/target core/metrics/.native/target core/all/.native/target core/metrics/.jvm/target core/all/.js/target java/all/target java/common/target core/metrics/.js/target core/all/.jvm/target core/trace/.native/target semconv/.js/target core/trace/.jvm/target core/common/.native/target core/common/.js/target semconv/.native/target testkit/all/jvm/target project/target + run: tar cf targets.tar testkit/metrics/jvm/target java/metrics/target testkit/common/jvm/target core/trace/.js/target semconv/.jvm/target core/common/.jvm/target java/trace/target unidocs/target core/metrics/.native/target core/all/.native/target core/metrics/.jvm/target java/context-storage/target core/all/.js/target java/all/target java/common/target core/metrics/.js/target core/all/.jvm/target core/trace/.native/target semconv/.js/target core/trace/.jvm/target core/common/.native/target core/common/.js/target semconv/.native/target testkit/all/jvm/target project/target - name: Upload target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') diff --git a/build.sbt b/build.sbt index 099dd8c35..a08b8887a 100644 --- a/build.sbt +++ b/build.sbt @@ -28,7 +28,7 @@ ThisBuild / crossScalaVersions := Seq(Scala213, "3.3.1") ThisBuild / scalaVersion := Scala213 // the default Scala val CatsVersion = "2.10.0" -val CatsEffectVersion = "3.5.2" +val CatsEffectVersion = "3.6-02a43a6" val CatsMtlVersion = "1.3.1" val DisciplineMUnitVersion = "2.0.0-M3" val FS2Version = "3.9.2" @@ -67,6 +67,7 @@ lazy val root = tlCrossRootProject `testkit-metrics`, testkit, `java-common`, + `java-context-storage`, `java-metrics`, `java-trace`, java, @@ -288,9 +289,27 @@ lazy val `java-trace` = project ) .settings(scalafixSettings) +lazy val `java-context-storage` = project + .in(file("java/context-storage")) + .dependsOn(`java-common`) + .settings(munitDependencies) + .settings( + name := "otel4s-java-context-storage", + libraryDependencies ++= Seq( + "org.typelevel" %%% "cats-effect" % CatsEffectVersion, + "org.typelevel" %%% "cats-effect-testkit" % CatsEffectVersion % Test, + ), + Test / javaOptions ++= Seq( + "-Dotel.java.global-autoconfigure.enabled=true", + "-Dcats.effect.ioLocalPropagation=true", + ), + Test / fork := true, + ) + .settings(scalafixSettings) + lazy val java = project .in(file("java/all")) - .dependsOn(core.jvm, `java-metrics`, `java-trace`) + .dependsOn(core.jvm, `java-metrics`, `java-trace`, `java-context-storage`) .settings( name := "otel4s-java", libraryDependencies ++= Seq( @@ -334,6 +353,7 @@ lazy val examples = project ), run / fork := true, javaOptions += "-Dotel.java.global-autoconfigure.enabled=true", + javaOptions += "-Dcats.effect.ioLocalPropagation=true", envVars ++= Map( "OTEL_PROPAGATORS" -> "b3multi", "OTEL_SERVICE_NAME" -> "Trace Example" diff --git a/examples/src/main/scala/ContextStorageExample.scala b/examples/src/main/scala/ContextStorageExample.scala new file mode 100644 index 000000000..de155209f --- /dev/null +++ b/examples/src/main/scala/ContextStorageExample.scala @@ -0,0 +1,48 @@ +/* + * Copyright 2022 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import cats.effect.IO +import cats.effect.IOApp +import cats.effect.Resource +import io.opentelemetry.context.Context +import io.opentelemetry.context.ContextKey +import io.opentelemetry.context.ContextStorage + +import java.util.logging._ + +object ContextStorageExample extends IOApp.Simple { + + val key = ContextKey.named[String]("test") + + val printKey = + IO(Option(Context.current().get(key))).flatMap(v => IO.println(v)) + + def run = + for { + _ <- IO { + val rootLog = Logger.getLogger("") + rootLog.setLevel(Level.FINE) + rootLog.getHandlers().head.setLevel(Level.FINE) + } + _ <- IO.println(ContextStorage.get.getClass()) + _ <- Resource + .make(IO(Context.root().`with`(key, "hello").makeCurrent()))(scope => + IO(scope.close()) + ) + .surround(printKey) + _ <- printKey + } yield () +} diff --git a/java/common/src/main/scala/org/typelevel/otel4s/java/context/Context.scala b/java/common/src/main/scala/org/typelevel/otel4s/java/context/Context.scala index 6c224b1f2..d3996fb76 100644 --- a/java/common/src/main/scala/org/typelevel/otel4s/java/context/Context.scala +++ b/java/common/src/main/scala/org/typelevel/otel4s/java/context/Context.scala @@ -95,7 +95,7 @@ object Context { } /** The root [[`Context`]], from which all other contexts are derived. */ - val root: Context = wrap(JContext.root()) + lazy val root: Context = wrap(JContext.root()) implicit object Contextual extends context.Contextual[Context] { type Key[A] = Context.Key[A] diff --git a/java/context-storage/src/main/resources/META-INF/services/io.opentelemetry.context.ContextStorageProvider b/java/context-storage/src/main/resources/META-INF/services/io.opentelemetry.context.ContextStorageProvider new file mode 100644 index 000000000..6b56f5271 --- /dev/null +++ b/java/context-storage/src/main/resources/META-INF/services/io.opentelemetry.context.ContextStorageProvider @@ -0,0 +1 @@ +org.typelevel.otel4s.java.IOLocalContextStorageProvider diff --git a/java/context-storage/src/main/scala/org/typelevel/otel4s/java/IOLocalContextStorage.scala b/java/context-storage/src/main/scala/org/typelevel/otel4s/java/IOLocalContextStorage.scala new file mode 100644 index 000000000..3e0edc1c6 --- /dev/null +++ b/java/context-storage/src/main/scala/org/typelevel/otel4s/java/IOLocalContextStorage.scala @@ -0,0 +1,94 @@ +/* + * Copyright 2022 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.typelevel.otel4s.java + +import cats.effect.IOLocal +import cats.effect.LiftIO +import cats.effect.MonadCancelThrow +import cats.effect.unsafe.IOLocals +import io.opentelemetry.context.{Context => JContext} +import io.opentelemetry.context.ContextStorage +import io.opentelemetry.context.Scope +import org.typelevel.otel4s.java.context.Context +import org.typelevel.otel4s.java.context.LocalContext +import org.typelevel.otel4s.java.instances._ + +/** A `ContextStorage` backed by an [[cats.effect.IOLocal `IOLocal`]] of a + * [[org.typelevel.otel4s.java.context.Context `Context`]] that also provides + * [[cats.mtl.Local `Local`]] instances that reflect the state of the backing + * `IOLocal`. Usage of `Local` and `ContextStorage` methods will be consistent + * and stay in sync as long as effects are threaded properly. + */ +class IOLocalContextStorage(_ioLocal: () => IOLocal[Context]) + extends ContextStorage { + private[this] implicit lazy val ioLocal: IOLocal[Context] = _ioLocal() + + @inline private[this] def unsafeCurrent: Context = + IOLocals.get(ioLocal) + + override def attach(toAttach: JContext): Scope = { + val previous = unsafeCurrent + IOLocals.set(ioLocal, Context.wrap(toAttach)) + () => IOLocals.set(ioLocal, previous) + } + + override def current(): JContext = + unsafeCurrent.underlying + + /** @return + * a [[cats.mtl.Local `Local`]] of a + * [[org.typelevel.otel4s.java.context.Context `Context`]] that reflects + * the state of the backing `IOLocal` + */ + def local[F[_]: MonadCancelThrow: LiftIO]: LocalContext[F] = implicitly +} + +object IOLocalContextStorage { + + /** Returns a [[cats.mtl.Local `Local`]] of a + * [[org.typelevel.otel4s.java.context.Context `Context`]] if an + * [[`IOLocalContextStorage`]] is configured to be used as the + * `ContextStorage` for the Java otel library. + * + * Raises an exception if an [[`IOLocalContextStorage`]] is __not__ + * configured to be used as the `ContextStorage` for the Java otel library, + * or if [[cats.effect.IOLocal `IOLocal`]] propagation is not enabled. + */ + def providedLocal[F[_]: LiftIO](implicit + F: MonadCancelThrow[F] + ): F[LocalContext[F]] = + ContextStorage.get() match { + case storage: IOLocalContextStorage => + // TODO: check `IOLocals.arePropagating` instead once our dependencies + // are updated + if (java.lang.Boolean.getBoolean("cats.effect.ioLocalPropagation")) { + F.pure(storage.local) + } else { + F.raiseError( + new IllegalStateException( + "IOLocal propagation must be enabled with: -Dcats.effect.ioLocalPropagation=true" + ) + ) + } + case _ => + F.raiseError( + new IllegalStateException( + "IOLocalContextStorage is not configured for use as the ContextStorageProvider" + ) + ) + } +} diff --git a/java/context-storage/src/main/scala/org/typelevel/otel4s/java/IOLocalContextStorageProvider.scala b/java/context-storage/src/main/scala/org/typelevel/otel4s/java/IOLocalContextStorageProvider.scala new file mode 100644 index 000000000..0c578051b --- /dev/null +++ b/java/context-storage/src/main/scala/org/typelevel/otel4s/java/IOLocalContextStorageProvider.scala @@ -0,0 +1,44 @@ +/* + * Copyright 2022 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.typelevel.otel4s.java + +import cats.effect.IOLocal +import cats.effect.SyncIO +import cats.syntax.all._ +import io.opentelemetry.context.ContextStorage +import io.opentelemetry.context.ContextStorageProvider +import org.typelevel.otel4s.java.context.Context + +object IOLocalContextStorageProvider { + private lazy val localContext: IOLocal[Context] = + IOLocal[Context](Context.root) + .syncStep(100) + .flatMap( + _.leftMap(_ => + new Error( + "Failed to initialize the local context of the IOLocalContextStorageProvider." + ) + ).liftTo[SyncIO] + ) + .unsafeRunSync() +} + +/** SPI implementation for [[`IOLocalContextStorage`]]. */ +class IOLocalContextStorageProvider extends ContextStorageProvider { + def get(): ContextStorage = + new IOLocalContextStorage(() => IOLocalContextStorageProvider.localContext) +} diff --git a/java/context-storage/src/test/scala/org/typelevel/otel4s/java/IOLocalContextStorageSuite.scala b/java/context-storage/src/test/scala/org/typelevel/otel4s/java/IOLocalContextStorageSuite.scala new file mode 100644 index 000000000..b7611887e --- /dev/null +++ b/java/context-storage/src/test/scala/org/typelevel/otel4s/java/IOLocalContextStorageSuite.scala @@ -0,0 +1,264 @@ +/* + * Copyright 2022 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.typelevel.otel4s.java + +import cats.effect.IO +import cats.effect.SyncIO +import io.opentelemetry.context.{Context => JContext} +import io.opentelemetry.context.ContextStorage +import munit.CatsEffectSuite +import munit.Location +import org.typelevel.otel4s.context.Key +import org.typelevel.otel4s.java.context.Context +import org.typelevel.otel4s.java.context.LocalContext + +import scala.util.Using + +class IOLocalContextStorageSuite extends CatsEffectSuite { + import IOLocalContextStorageSuite._ + + private val localF: IO[LocalContext[IO]] = + IOLocalContextStorage.providedLocal + + private def sCurrent[F[_]](implicit L: LocalContext[F]): F[Context] = + L.ask[Context] + private def jCurrent: JContext = JContext.current() + + private def usingModifiedCtx[A](f: JContext => JContext)(body: => A): A = + Using.resource(f(jCurrent).makeCurrent())(_ => body) + + private def localTest( + name: String + )(body: LocalContext[IO] => IO[Any])(implicit loc: Location): Unit = + test(name) { + for { + local <- localF + _ <- body(local) + } yield () + } + + // if this fails, the rest will almost certainly fail, + // and will be meaningless regardless + localTest("correctly configured") { implicit L => + for { + sCtx <- sCurrent + jCtx <- IO(jCurrent) + } yield { + // correct ContextStorage is configured + assertEquals( + ContextStorage.get().getClass: Any, + classOf[IOLocalContextStorage]: Any + ) + + // current is root + assertEquals(JContext.root(), Context.root.underlying) + assertEquals(jCtx, sCtx.underlying) + assertEquals(sCtx, Context.root) + assertEquals(jCtx, JContext.root()) + + // root is empty + assertEquals(sCtx.get(key1), None) + assertEquals(sCtx.get(key2), None) + assertEquals(Option(jCtx.get(key1)), None) + assertEquals(Option(jCtx.get(key2)), None) + } + } + + test("works as a Java-only ContextStorage") { + usingModifiedCtx(_.`with`(key1, "1")) { + assertEquals(Option(jCurrent.get(key1)), Some("1")) + assertEquals(Option(jCurrent.get(key2)), None) + + usingModifiedCtx(_.`with`(key2, 2)) { + assertEquals(Option(jCurrent.get(key1)), Some("1")) + assertEquals(Option(jCurrent.get(key2)), Some(2)) + + usingModifiedCtx(_ => JContext.root()) { + assertEquals(Option(jCurrent.get(key1)), None) + assertEquals(Option(jCurrent.get(key2)), None) + } + } + } + } + + localTest("works as a Scala-only Local") { implicit L => + doLocally(_.updated(key1, "1")) { + for { + _ <- doLocally(_.updated(key2, 2)) { + for { + _ <- doScoped(Context.root) { + for (ctx <- sCurrent) + yield { + assertEquals(ctx.get(key1), None) + assertEquals(ctx.get(key2), None) + } + } + ctx <- sCurrent + } yield { + assertEquals(ctx.get(key1), Some("1")) + assertEquals(ctx.get(key2), Some(2)) + } + } + ctx <- sCurrent + } yield { + assertEquals(ctx.get(key1), Some("1")) + assertEquals(ctx.get(key2), None) + } + } + } + + localTest("Scala with Java nested inside it") { implicit L => + doLocally(_.updated(key1, "1")) { + for { + _ <- IO { + usingModifiedCtx(_.`with`(key2, 2)) { + val sCtx = sCurrent.unsafeRunSync() + val jCtx = jCurrent + assertEquals(jCtx, sCtx.underlying) + assertEquals(sCtx.get(key1), Some("1")) + assertEquals(sCtx.get(key2), Some(2)) + assertEquals(Option(jCtx.get(key1)), Some("1")) + assertEquals(Option(jCtx.get(key2)), Some(2)) + } + } + sCtx <- sCurrent + jCtx <- IO(jCurrent) + } yield { + assertEquals(jCtx, sCtx.underlying) + assertEquals(sCtx.get(key1), Some("1")) + assertEquals(sCtx.get(key2), None) + assertEquals(Option(jCtx.get(key1)), Some("1")) + assertEquals(Option(jCtx.get(key2)), None) + } + } + } + + localTest("Java with Scala nested inside it") { implicit L => + IO { + usingModifiedCtx(_.`with`(key1, "1")) { + val sCtx = locally { + for { + _ <- doLocally(_.updated(key2, 2)) { + for { + sCtx <- sCurrent + jCtx <- IO(jCurrent) + } yield { + assertEquals(jCtx, sCtx.underlying) + assertEquals(sCtx.get(key1), Some("1")) + assertEquals(sCtx.get(key2), Some(2)) + assertEquals(Option(jCtx.get(key1)), Some("1")) + assertEquals(Option(jCtx.get(key2)), Some(2)) + } + } + ctx <- sCurrent + } yield ctx + }.unsafeRunSync() + val jCtx = jCurrent + assertEquals(jCtx, sCtx.underlying) + assertEquals(sCtx.get(key1), Some("1")) + assertEquals(sCtx.get(key2), None) + assertEquals(Option(jCtx.get(key1)), Some("1")) + assertEquals(Option(jCtx.get(key2)), None) + } + } + } + + localTest("lots of nesting") { implicit L => + doLocally(_.updated(key1, "1")) { + for { + _ <- IO { + usingModifiedCtx(_.`with`(key2, 2)) { + usingModifiedCtx(_.`with`(key1, "3")) { + val sCtx = locally { + for { + _ <- doLocally(_.updated(key2, 4)) { + for { + sCtx <- sCurrent + jCtx <- IO(jCurrent) + } yield { + assertEquals(jCtx, sCtx.underlying) + assertEquals(sCtx.get(key1), Some("3")) + assertEquals(sCtx.get(key2), Some(4)) + assertEquals(Option(jCtx.get(key1)), Some("3")) + assertEquals(Option(jCtx.get(key2)), Some(4)) + } + } + ctx <- sCurrent + } yield ctx + }.unsafeRunSync() + val jCtx = jCurrent + assertEquals(jCtx, sCtx.underlying) + assertEquals(sCtx.get(key1), Some("3")) + assertEquals(sCtx.get(key2), Some(2)) + assertEquals(Option(jCtx.get(key1)), Some("3")) + assertEquals(Option(jCtx.get(key2)), Some(2)) + } + val sCtx = locally { + for { + _ <- doScoped(Context.root) { + for { + sCtx <- sCurrent + jCtx <- IO(jCurrent) + } yield { + assertEquals(jCtx, sCtx.underlying) + assertEquals(sCtx.get(key1), None) + assertEquals(sCtx.get(key2), None) + assertEquals(Option(jCtx.get(key1)), None) + assertEquals(Option(jCtx.get(key2)), None) + } + } + ctx <- sCurrent + } yield ctx + }.unsafeRunSync() + val jCtx = jCurrent + assertEquals(jCtx, sCtx.underlying) + assertEquals(sCtx.get(key1), Some("1")) + assertEquals(sCtx.get(key2), Some(2)) + assertEquals(Option(jCtx.get(key1)), Some("1")) + assertEquals(Option(jCtx.get(key2)), Some(2)) + } + } + sCtx <- sCurrent + jCtx <- IO(jCurrent) + } yield { + assertEquals(jCtx, sCtx.underlying) + assertEquals(sCtx.get(key1), Some("1")) + assertEquals(sCtx.get(key2), None) + assertEquals(Option(jCtx.get(key1)), Some("1")) + assertEquals(Option(jCtx.get(key2)), None) + } + } + } +} + +object IOLocalContextStorageSuite { + private val keyProvider = Key.Provider[SyncIO, Context.Key] + val key1: Context.Key[String] = + keyProvider.uniqueKey[String]("key1").unsafeRunSync() + val key2: Context.Key[Int] = + keyProvider.uniqueKey[Int]("key2").unsafeRunSync() + + // `Local`'s methods have their argument lists in the an annoying order + def doLocally[F[_], A](f: Context => Context)(fa: F[A])(implicit + L: LocalContext[F] + ): F[A] = + L.local(fa)(f) + def doScoped[F[_], A](e: Context)(fa: F[A])(implicit + L: LocalContext[F] + ): F[A] = + L.scope(fa)(e) +}