From b48f3b1eb6edff0494e61680061f887c111c45ea Mon Sep 17 00:00:00 2001 From: Marissa Date: Thu, 31 Aug 2023 06:17:51 -0400 Subject: [PATCH] Finish implementing `IOLocalContextStorage` Finish implementing `IOLocalContextStorage` and `IOLocalContextStorageProvider` for sharing context modifications between Java and Scala instrumentation. --- .github/workflows/ci.yml | 4 +- build.sbt | 21 +- ...entelemetry.context.ContextStorageProvider | 1 - .../otel4s/java/IOLocalContextStorage.scala | 40 --- .../otel4s/java/context/Context.scala | 2 +- ...entelemetry.context.ContextStorageProvider | 1 + .../otel4s/java/IOLocalContextStorage.scala | 92 ++++++ .../java/IOLocalContextStorageProvider.scala | 14 +- .../java/IOLocalContextStorageSuite.scala | 264 ++++++++++++++++++ 9 files changed, 387 insertions(+), 52 deletions(-) delete mode 100644 java/common/src/main/resources/META-INF/services/io.opentelemetry.context.ContextStorageProvider delete mode 100644 java/common/src/main/scala/org/typelevel/otel4s/java/IOLocalContextStorage.scala create mode 100644 java/context-storage/src/main/resources/META-INF/services/io.opentelemetry.context.ContextStorageProvider create mode 100644 java/context-storage/src/main/scala/org/typelevel/otel4s/java/IOLocalContextStorage.scala rename java/{common => context-storage}/src/main/scala/org/typelevel/otel4s/java/IOLocalContextStorageProvider.scala (74%) create mode 100644 java/context-storage/src/test/scala/org/typelevel/otel4s/java/IOLocalContextStorageSuite.scala 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 547448ab7..a08b8887a 100644 --- a/build.sbt +++ b/build.sbt @@ -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( diff --git a/java/common/src/main/resources/META-INF/services/io.opentelemetry.context.ContextStorageProvider b/java/common/src/main/resources/META-INF/services/io.opentelemetry.context.ContextStorageProvider deleted file mode 100644 index aeea4e705..000000000 --- a/java/common/src/main/resources/META-INF/services/io.opentelemetry.context.ContextStorageProvider +++ /dev/null @@ -1 +0,0 @@ -org.typelevel.otel4s.java.IOLocalContextStorageProvider \ No newline at end of file diff --git a/java/common/src/main/scala/org/typelevel/otel4s/java/IOLocalContextStorage.scala b/java/common/src/main/scala/org/typelevel/otel4s/java/IOLocalContextStorage.scala deleted file mode 100644 index e18a80b38..000000000 --- a/java/common/src/main/scala/org/typelevel/otel4s/java/IOLocalContextStorage.scala +++ /dev/null @@ -1,40 +0,0 @@ -/* - * 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.unsafe.IOLocals -import io.opentelemetry.context.Context -import io.opentelemetry.context.ContextStorage -import io.opentelemetry.context.Scope - -class IOLocalContextStorage( - _ioLocal: => IOLocal[Context] -) extends ContextStorage { - private[this] lazy val ioLocal = _ioLocal - override def attach(toAttach: Context): Scope = { - val previous = current() - IOLocals.set(ioLocal, toAttach) - new Scope { - def close() = IOLocals.set(ioLocal, previous) - } - } - - override def current(): Context = - IOLocals.get(ioLocal) - -} 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..ed767622e --- /dev/null +++ b/java/context-storage/src/main/scala/org/typelevel/otel4s/java/IOLocalContextStorage.scala @@ -0,0 +1,92 @@ +/* + * 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 + * [[`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 [[`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 [[`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/common/src/main/scala/org/typelevel/otel4s/java/IOLocalContextStorageProvider.scala b/java/context-storage/src/main/scala/org/typelevel/otel4s/java/IOLocalContextStorageProvider.scala similarity index 74% rename from java/common/src/main/scala/org/typelevel/otel4s/java/IOLocalContextStorageProvider.scala rename to java/context-storage/src/main/scala/org/typelevel/otel4s/java/IOLocalContextStorageProvider.scala index d328674d2..73c77b81e 100644 --- a/java/common/src/main/scala/org/typelevel/otel4s/java/IOLocalContextStorageProvider.scala +++ b/java/context-storage/src/main/scala/org/typelevel/otel4s/java/IOLocalContextStorageProvider.scala @@ -16,16 +16,15 @@ package org.typelevel.otel4s.java -import cats.effect.IOLocal -import cats.effect.SyncIO -import cats.syntax.all._ -import io.opentelemetry.context.Context +import cats.effect.{IOLocal, LiftIO, MonadCancelThrow, SyncIO} +import cats.syntax.all.* import io.opentelemetry.context.ContextStorage import io.opentelemetry.context.ContextStorageProvider +import org.typelevel.otel4s.java.context.{Context, LocalContext} object IOLocalContextStorageProvider { - val localContext: IOLocal[Context] = - IOLocal[Context](Context.root()) + private lazy val localContext: IOLocal[Context] = + IOLocal[Context](Context.root) .syncStep(100) .flatMap( _.leftMap(_ => @@ -37,7 +36,8 @@ object IOLocalContextStorageProvider { .unsafeRunSync() } +/** SPI implementation for [[`IOLocalContextStorage`]]. */ class IOLocalContextStorageProvider extends ContextStorageProvider { def get(): ContextStorage = - new IOLocalContextStorage(IOLocalContextStorageProvider.localContext) + 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) +}