diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b29f7a909..d2a6b0cc3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,11 +91,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 semconv/stable/.jvm/target oteljava/metrics/target instrumentation/metrics/js/target sdk-exporter/common/.js/target sdk/common/native/target sdk/common/js/target core/trace/.js/target semconv/metrics/stable/.jvm/target semconv/metrics/experimental/.jvm/target semconv/metrics/stable/.native/target sdk-exporter/all/.jvm/target sdk-exporter/prometheus/.js/target semconv/experimental/.js/target sdk/trace/.js/target core/common/.jvm/target oteljava/common-testkit/target sdk/metrics/.native/target sdk-exporter/metrics/.jvm/target sdk-exporter/trace/.jvm/target unidocs/target sdk-contrib/aws/resource/.jvm/target oteljava/trace-testkit/target core/metrics/.native/target core/all/.native/target sdk/trace-testkit/.jvm/target sdk/trace-testkit/.native/target sdk/testkit/.native/target sdk-exporter/prometheus/.jvm/target sdk-contrib/aws/resource/.js/target semconv/experimental/.native/target core/metrics/.jvm/target core/all/.js/target sdk-exporter/proto/.jvm/target sdk-exporter/proto/.js/target sdk-exporter/metrics/.js/target semconv/stable/.native/target sdk/all/.native/target sdk/metrics-testkit/.js/target sdk-contrib/aws/xray-propagator/.native/target core/metrics/.js/target sdk/testkit/.js/target core/all/.jvm/target sdk/common/jvm/target core/trace/.native/target oteljava/metrics-testkit/target instrumentation/metrics/jvm/target sdk/trace/.native/target semconv/experimental/.jvm/target sdk/metrics-testkit/.native/target sdk/metrics/.jvm/target oteljava/common/target scalafix/rules/target sdk-exporter/proto/.native/target core/trace/.jvm/target sdk-exporter/common/.jvm/target sdk/metrics-testkit/.jvm/target sdk/metrics/.js/target sdk-exporter/trace/.js/target core/common/.native/target sdk/trace-testkit/.js/target core/common/.js/target oteljava/trace/target semconv/metrics/experimental/.native/target oteljava/testkit/target sdk/testkit/.jvm/target sdk-exporter/all/.js/target sdk-contrib/aws/xray/.native/target sdk-contrib/aws/xray/.js/target sdk-contrib/aws/xray-propagator/.js/target semconv/metrics/experimental/.js/target semconv/metrics/stable/.js/target instrumentation/metrics/native/target sdk/all/.js/target sdk/all/.jvm/target sdk-exporter/all/.native/target oteljava/all/target sdk/trace/.jvm/target sdk-contrib/aws/xray-propagator/.jvm/target semconv/stable/.js/target sdk-contrib/aws/xray/.jvm/target project/target + run: mkdir -p semconv/stable/.jvm/target oteljava/metrics/target oteljava/context-storage/target instrumentation/metrics/js/target sdk/common/native/target core/trace/.js/target semconv/metrics/stable/.jvm/target semconv/metrics/experimental/.jvm/target semconv/metrics/stable/.native/target sdk-exporter/all/.jvm/target semconv/experimental/.js/target core/common/.jvm/target oteljava/common-testkit/target sdk/metrics/.native/target sdk-exporter/metrics/.jvm/target sdk-exporter/trace/.jvm/target unidocs/target sdk-contrib/aws/resource/.jvm/target oteljava/trace-testkit/target core/metrics/.native/target core/all/.native/target sdk/trace-testkit/.jvm/target sdk/trace-testkit/.native/target sdk/testkit/.native/target sdk-exporter/prometheus/.jvm/target semconv/experimental/.native/target core/metrics/.jvm/target core/all/.js/target sdk-exporter/proto/.jvm/target sdk-exporter/proto/.js/target semconv/stable/.native/target sdk/all/.native/target sdk/metrics-testkit/.js/target sdk-contrib/aws/xray-propagator/.native/target core/metrics/.js/target sdk/testkit/.js/target core/all/.jvm/target sdk/common/jvm/target core/trace/.native/target oteljava/metrics-testkit/target instrumentation/metrics/jvm/target sdk/trace/.native/target semconv/experimental/.jvm/target sdk/metrics-testkit/.native/target sdk/metrics/.jvm/target oteljava/common/target scalafix/rules/target sdk-exporter/proto/.native/target core/trace/.jvm/target sdk-exporter/common/.jvm/target sdk/metrics-testkit/.jvm/target core/common/.native/target sdk/trace-testkit/.js/target core/common/.js/target oteljava/trace/target semconv/metrics/experimental/.native/target oteljava/testkit/target sdk/testkit/.jvm/target sdk-exporter/all/.js/target sdk-contrib/aws/xray/.native/target semconv/metrics/experimental/.js/target semconv/metrics/stable/.js/target instrumentation/metrics/native/target sdk/all/.jvm/target sdk-exporter/all/.native/target oteljava/all/target sdk/trace/.jvm/target sdk-contrib/aws/xray-propagator/.jvm/target semconv/stable/.js/target sdk-contrib/aws/xray/.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 semconv/stable/.jvm/target oteljava/metrics/target instrumentation/metrics/js/target sdk-exporter/common/.js/target sdk/common/native/target sdk/common/js/target core/trace/.js/target semconv/metrics/stable/.jvm/target semconv/metrics/experimental/.jvm/target semconv/metrics/stable/.native/target sdk-exporter/all/.jvm/target sdk-exporter/prometheus/.js/target semconv/experimental/.js/target sdk/trace/.js/target core/common/.jvm/target oteljava/common-testkit/target sdk/metrics/.native/target sdk-exporter/metrics/.jvm/target sdk-exporter/trace/.jvm/target unidocs/target sdk-contrib/aws/resource/.jvm/target oteljava/trace-testkit/target core/metrics/.native/target core/all/.native/target sdk/trace-testkit/.jvm/target sdk/trace-testkit/.native/target sdk/testkit/.native/target sdk-exporter/prometheus/.jvm/target sdk-contrib/aws/resource/.js/target semconv/experimental/.native/target core/metrics/.jvm/target core/all/.js/target sdk-exporter/proto/.jvm/target sdk-exporter/proto/.js/target sdk-exporter/metrics/.js/target semconv/stable/.native/target sdk/all/.native/target sdk/metrics-testkit/.js/target sdk-contrib/aws/xray-propagator/.native/target core/metrics/.js/target sdk/testkit/.js/target core/all/.jvm/target sdk/common/jvm/target core/trace/.native/target oteljava/metrics-testkit/target instrumentation/metrics/jvm/target sdk/trace/.native/target semconv/experimental/.jvm/target sdk/metrics-testkit/.native/target sdk/metrics/.jvm/target oteljava/common/target scalafix/rules/target sdk-exporter/proto/.native/target core/trace/.jvm/target sdk-exporter/common/.jvm/target sdk/metrics-testkit/.jvm/target sdk/metrics/.js/target sdk-exporter/trace/.js/target core/common/.native/target sdk/trace-testkit/.js/target core/common/.js/target oteljava/trace/target semconv/metrics/experimental/.native/target oteljava/testkit/target sdk/testkit/.jvm/target sdk-exporter/all/.js/target sdk-contrib/aws/xray/.native/target sdk-contrib/aws/xray/.js/target sdk-contrib/aws/xray-propagator/.js/target semconv/metrics/experimental/.js/target semconv/metrics/stable/.js/target instrumentation/metrics/native/target sdk/all/.js/target sdk/all/.jvm/target sdk-exporter/all/.native/target oteljava/all/target sdk/trace/.jvm/target sdk-contrib/aws/xray-propagator/.jvm/target semconv/stable/.js/target sdk-contrib/aws/xray/.jvm/target project/target + run: tar cf targets.tar semconv/stable/.jvm/target oteljava/metrics/target oteljava/context-storage/target instrumentation/metrics/js/target sdk/common/native/target core/trace/.js/target semconv/metrics/stable/.jvm/target semconv/metrics/experimental/.jvm/target semconv/metrics/stable/.native/target sdk-exporter/all/.jvm/target semconv/experimental/.js/target core/common/.jvm/target oteljava/common-testkit/target sdk/metrics/.native/target sdk-exporter/metrics/.jvm/target sdk-exporter/trace/.jvm/target unidocs/target sdk-contrib/aws/resource/.jvm/target oteljava/trace-testkit/target core/metrics/.native/target core/all/.native/target sdk/trace-testkit/.jvm/target sdk/trace-testkit/.native/target sdk/testkit/.native/target sdk-exporter/prometheus/.jvm/target semconv/experimental/.native/target core/metrics/.jvm/target core/all/.js/target sdk-exporter/proto/.jvm/target sdk-exporter/proto/.js/target semconv/stable/.native/target sdk/all/.native/target sdk/metrics-testkit/.js/target sdk-contrib/aws/xray-propagator/.native/target core/metrics/.js/target sdk/testkit/.js/target core/all/.jvm/target sdk/common/jvm/target core/trace/.native/target oteljava/metrics-testkit/target instrumentation/metrics/jvm/target sdk/trace/.native/target semconv/experimental/.jvm/target sdk/metrics-testkit/.native/target sdk/metrics/.jvm/target oteljava/common/target scalafix/rules/target sdk-exporter/proto/.native/target core/trace/.jvm/target sdk-exporter/common/.jvm/target sdk/metrics-testkit/.jvm/target core/common/.native/target sdk/trace-testkit/.js/target core/common/.js/target oteljava/trace/target semconv/metrics/experimental/.native/target oteljava/testkit/target sdk/testkit/.jvm/target sdk-exporter/all/.js/target sdk-contrib/aws/xray/.native/target semconv/metrics/experimental/.js/target semconv/metrics/stable/.js/target instrumentation/metrics/native/target sdk/all/.jvm/target sdk-exporter/all/.native/target oteljava/all/target sdk/trace/.jvm/target sdk-contrib/aws/xray-propagator/.jvm/target semconv/stable/.js/target sdk-contrib/aws/xray/.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 8f896e977..cb8503011 100644 --- a/build.sbt +++ b/build.sbt @@ -28,7 +28,9 @@ lazy val scalaJSLinkerSettings = Def.settings( scalaJSLinkerConfig ~= (_.withESFeatures( _.withESVersion(org.scalajs.linker.interface.ESVersion.ES2018) )), - Test / scalaJSLinkerConfig ~= (_.withModuleKind(ModuleKind.CommonJSModule)) + Test / scalaJSLinkerConfig ~= (_.withModuleKind(ModuleKind.CommonJSModule)), + // the JS artifacts could be quite large and exceed the CI disk space limit + githubWorkflowArtifactUpload := false ) lazy val scalaNativeSettings = Def.settings( @@ -154,7 +156,7 @@ lazy val root = tlCrossRootProject `oteljava-trace`, `oteljava-trace-testkit`, `oteljava-testkit`, - oteljava, + `oteljava-context-storage`, `semconv-stable`, `semconv-experimental`, `semconv-metrics-stable`, @@ -722,6 +724,22 @@ lazy val `oteljava-testkit` = project ) .settings(scalafixSettings) +lazy val `oteljava-context-storage` = project + .in(file("oteljava/context-storage")) + .dependsOn(`oteljava-common`) + .settings(munitDependencies) + .settings( + name := "otel4s-oteljava-context-storage", + libraryDependencies ++= Seq( + "org.typelevel" %%% "cats-effect-testkit" % CatsEffectVersion % Test, + ), + Test / javaOptions ++= Seq( + "-Dcats.effect.trackFiberContext=true", + ), + Test / fork := true, + ) + .settings(scalafixSettings) + lazy val oteljava = project .in(file("oteljava/all")) .dependsOn( @@ -854,7 +872,7 @@ lazy val benchmarks = project lazy val examples = project .enablePlugins(NoPublishPlugin, JavaAgent) .in(file("examples")) - .dependsOn(core.jvm, oteljava, sdk.jvm, `sdk-exporter`.jvm, `sdk-exporter-prometheus`.jvm) + .dependsOn(core.jvm, oteljava, `oteljava-context-storage`, sdk.jvm, `sdk-exporter`.jvm, `sdk-exporter-prometheus`.jvm) .settings( name := "otel4s-examples", libraryDependencies ++= Seq( @@ -869,6 +887,7 @@ lazy val examples = project javaAgents += "io.opentelemetry.javaagent" % "opentelemetry-javaagent" % OpenTelemetryInstrumentationVersion % Runtime, run / fork := true, javaOptions += "-Dotel.java.global-autoconfigure.enabled=true", + javaOptions += "-Dcats.effect.trackFiberContext=true", envVars ++= Map( "OTEL_PROPAGATORS" -> "b3multi", "OTEL_SERVICE_NAME" -> "Trace Example" @@ -881,6 +900,7 @@ lazy val docs = project .enablePlugins(TypelevelSitePlugin) .dependsOn( oteljava, + `oteljava-context-storage`, `oteljava-testkit`, `instrumentation-metrics`.jvm, sdk.jvm, @@ -905,6 +925,8 @@ lazy val docs = project "OPEN_TELEMETRY_VERSION" -> OpenTelemetryVersion, "OPEN_TELEMETRY_INSTRUMENTATION_ALPHA_VERSION" -> OpenTelemetryInstrumentationAlphaVersion ), + run / fork := true, + javaOptions += "-Dcats.effect.trackFiberContext=true", laikaConfig := { import laika.config.{ChoiceConfig, Selections, SelectionConfig} @@ -975,6 +997,7 @@ lazy val unidocs = project `oteljava-trace`, `oteljava-trace-testkit`, `oteljava-testkit`, + `oteljava-context-storage`, oteljava, `semconv-stable`.jvm, `semconv-experimental`.jvm, diff --git a/core/common/src/main/scala/org/typelevel/otel4s/context/LocalProvider.scala b/core/common/src/main/scala/org/typelevel/otel4s/context/LocalProvider.scala index 5749c36c8..635b543c7 100644 --- a/core/common/src/main/scala/org/typelevel/otel4s/context/LocalProvider.scala +++ b/core/common/src/main/scala/org/typelevel/otel4s/context/LocalProvider.scala @@ -130,7 +130,7 @@ object LocalProvider extends LocalProviderLowPriority { /** Cats Effect 3.6 introduced `IOLocal#asLocal`. However, we need a variation for a polymorphic type. */ - private def localForIOLocal[F[_]: MonadCancelThrow: LiftIO, Ctx](ioLocal: IOLocal[Ctx]): Local[F, Ctx] = + private[otel4s] def localForIOLocal[F[_]: MonadCancelThrow: LiftIO, Ctx](ioLocal: IOLocal[Ctx]): Local[F, Ctx] = new Local[F, Ctx] { def applicative: Applicative[F] = Applicative[F] diff --git a/docs/oteljava/directory.conf b/docs/oteljava/directory.conf index a4fdce20e..b09a16267 100644 --- a/docs/oteljava/directory.conf +++ b/docs/oteljava/directory.conf @@ -3,6 +3,7 @@ laika.title = OtelJava laika.navigationOrder = [ overview.md metrics-jvm-runtime.md + tracing-context-propagation.md tracing-java-interop.md testkit.md ] diff --git a/docs/oteljava/tracing-context-propagation.md b/docs/oteljava/tracing-context-propagation.md new file mode 100644 index 000000000..ed34313f1 --- /dev/null +++ b/docs/oteljava/tracing-context-propagation.md @@ -0,0 +1,85 @@ +# Tracing | Context propagation + +[OpenTelemetry Java SDK][opentelemetry-java] and otel4s rely on different context manipulation approaches, +which aren't interoperable out of the box. +Java SDK utilizes ThreadLocal variables to share tracing information, +otel4s, on the other hand, uses [Local][cats-mtl-local]. + +Cats Effect 3.6.0 introduced a new method of fiber context tracking, +which can be integrated almost seamlessly with the OpenTelemetry Java SDK. + +## Getting started + +@:select(build-tool) + +@:choice(sbt) + +Add settings to the `build.sbt`: + +```scala +libraryDependencies ++= Seq( + "org.typelevel" %% "otel4s-oteljava" % "@VERSION@", // <1> + "org.typelevel" %% "otel4s-oteljava-context-storage" % "@VERSION@", // <2> +) +javaOptions += "-Dcats.effect.trackFiberContext=true" // <3> +``` + +@:choice(scala-cli) + +Add directives to the `*.scala` file: + +```scala +//> using dep "org.typelevel::otel4s-oteljava:@VERSION@" // <1> +//> using dep "org.typelevel::otel4s-oteljava-context-storage:@VERSION@" // <2> +//> using `java-opt` "-Dcats.effect.trackFiberContext=true" // <3> +``` + +@:@ + +1. Add the `otel4s-oteljava` library +2. Add the `otel4s-oteljava-context-storage` library +3. Enable Cats Effect fiber context tracking + +## Configuration + +You need to use `IOLocalContextStorage.localProvider[IO]` to provide the global context storage, backed by `IOLocal`: +```scala mdoc:silent +import cats.effect.IO +import io.opentelemetry.api.trace.{Span => JSpan} +import org.typelevel.otel4s.context.LocalProvider +import org.typelevel.otel4s.oteljava.IOLocalContextStorage +import org.typelevel.otel4s.oteljava.OtelJava +import org.typelevel.otel4s.oteljava.context.Context +import org.typelevel.otel4s.trace.Tracer + +def program(tracer: Tracer[IO]): IO[Unit] = + tracer.span("test").use { span => // start 'test' span using otel4s + println(s"jctx : ${JSpan.current().getSpanContext}") // get a span from a ThreadLocal var + IO.println(s"otel4s: ${span.context}") + } + +def run: IO[Unit] = { + implicit val provider: LocalProvider[IO, Context] = + IOLocalContextStorage.localProvider[IO] + + OtelJava.autoConfigured[IO]().use { otelJava => + otelJava.tracerProvider.tracer("com.service").get.flatMap { tracer => + program(tracer) + } + } +} +``` + +According to the output, the context is the same: +``` +jctx : SpanContext{traceId=58b8ed50a558ca53fcc64a0d80b5e662, spanId=fc25fe2c9fb41905, ...} +otel4s: SpanContext{traceId=58b8ed50a558ca53fcc64a0d80b5e662, spanId=fc25fe2c9fb41905, ...} +``` + +## Limitations + +The `IOLocalContextStorageProvider` doesn't work with [OpenTelemetry Java Agent][opentelemetry-java-agent]. + +[opentelemetry-java]: https://github.com/open-telemetry/opentelemetry-java +[opentelemetry-java-agent]: https://opentelemetry.io/docs/zero-code/java/agent/ +[cats-mtl-local]: https://typelevel.org/cats-mtl/mtl-classes/local.html diff --git a/examples/src/main/scala/ContextStorageExample.scala b/examples/src/main/scala/ContextStorageExample.scala new file mode 100644 index 000000000..b60e01d1a --- /dev/null +++ b/examples/src/main/scala/ContextStorageExample.scala @@ -0,0 +1,39 @@ +/* + * 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 io.opentelemetry.api.trace.{Span => JSpan} +import org.typelevel.otel4s.context.LocalProvider +import org.typelevel.otel4s.oteljava.IOLocalContextStorage +import org.typelevel.otel4s.oteljava.OtelJava +import org.typelevel.otel4s.oteljava.context.Context + +object ContextStorageExample extends IOApp.Simple { + + def run: IO[Unit] = { + implicit val provider: LocalProvider[IO, Context] = IOLocalContextStorage.localProvider[IO] + OtelJava.autoConfigured[IO]().use { otelJava => + otelJava.tracerProvider.tracer("com.service").get.flatMap { tracer => + tracer.span("test").use { span => // start 'test' span using otel4s + val jSpanContext = JSpan.current().getSpanContext // get a span from a ThreadLocal var + IO.println(s"jCtx: ${jSpanContext}, Otel4s ctx: ${span.context}") + } + } + } + } + +} diff --git a/oteljava/common/src/main/scala/org/typelevel/otel4s/oteljava/context/Context.scala b/oteljava/common/src/main/scala/org/typelevel/otel4s/oteljava/context/Context.scala index 3fe81518c..d6c34e4e4 100644 --- a/oteljava/common/src/main/scala/org/typelevel/otel4s/oteljava/context/Context.scala +++ b/oteljava/common/src/main/scala/org/typelevel/otel4s/oteljava/context/Context.scala @@ -80,7 +80,7 @@ object Context { Wrapped(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/oteljava/context-storage/src/main/resources/META-INF/services/io.opentelemetry.context.ContextStorageProvider b/oteljava/context-storage/src/main/resources/META-INF/services/io.opentelemetry.context.ContextStorageProvider new file mode 100644 index 000000000..f8eaf8bae --- /dev/null +++ b/oteljava/context-storage/src/main/resources/META-INF/services/io.opentelemetry.context.ContextStorageProvider @@ -0,0 +1 @@ +org.typelevel.otel4s.oteljava.IOLocalContextStorageProvider diff --git a/oteljava/context-storage/src/main/scala/org/typelevel/otel4s/oteljava/IOLocalContextStorage.scala b/oteljava/context-storage/src/main/scala/org/typelevel/otel4s/oteljava/IOLocalContextStorage.scala new file mode 100644 index 000000000..6dab114e5 --- /dev/null +++ b/oteljava/context-storage/src/main/scala/org/typelevel/otel4s/oteljava/IOLocalContextStorage.scala @@ -0,0 +1,112 @@ +/* + * 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.oteljava + +import cats.effect.IOLocal +import cats.effect.LiftIO +import cats.effect.MonadCancelThrow +import cats.effect.unsafe.IORuntime +import cats.mtl.Local +import io.opentelemetry.context.{Context => JContext} +import io.opentelemetry.context.ContextStorage +import io.opentelemetry.context.Scope +import org.typelevel.otel4s.context.LocalProvider +import org.typelevel.otel4s.oteljava.context.Context +import org.typelevel.otel4s.oteljava.context.LocalContext + +/** A `ContextStorage` backed by an [[cats.effect.IOLocal `IOLocal`]] of a + * [[org.typelevel.otel4s.oteljava.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] lazy val ioLocal: IOLocal[Context] = _ioLocal() + private[this] lazy val unsafeThreadLocal: ThreadLocal[Context] = { + val fiberLocal = ioLocal.unsafeThreadLocal() + + new ThreadLocal[Context] { + override def initialValue(): Context = + Context.root + + override def get(): Context = + if (IORuntime.isUnderFiberContext()) fiberLocal.get() else super.get() + + override def set(value: Context): Unit = + if (IORuntime.isUnderFiberContext()) fiberLocal.set(value) else super.set(value) + } + } + + @inline private[this] def unsafeCurrent: Context = + unsafeThreadLocal.get() + + override def attach(toAttach: JContext): Scope = { + val previous = unsafeCurrent + unsafeThreadLocal.set(Context.wrap(toAttach)) + () => unsafeThreadLocal.set(previous) + } + + override def current(): JContext = + unsafeCurrent.underlying + + /** @return + * a [[cats.mtl.Local `Local`]] of a [[org.typelevel.otel4s.oteljava.context.Context `Context`]] that reflects the + * state of the backing `IOLocal` + */ + def local[F[_]: MonadCancelThrow: LiftIO]: LocalContext[F] = LocalProvider.localForIOLocal(ioLocal) +} + +object IOLocalContextStorage { + + /** Returns a [[cats.mtl.Local `Local`]] of a [[org.typelevel.otel4s.oteljava.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. + * + * @example + * {{{ + * implicit val localProvider: LocalProvider[IO, Context] = IOLocalContextStorage.localProvider + * OtelJava.autoConfigured[IO].use { otel4s => + * ... + * } + * }}} + */ + def localProvider[F[_]: LiftIO](implicit F: MonadCancelThrow[F]): LocalProvider[F, Context] = + new LocalProvider[F, Context] { + def local: F[Local[F, Context]] = + ContextStorage.get() match { + case storage: IOLocalContextStorage => + if (IOLocal.isPropagating) { + F.pure(storage.local) + } else { + F.raiseError( + new IllegalStateException( + "IOLocal propagation must be enabled with: -Dcats.effect.trackFiberContext=true" + ) + ) + } + case other => + F.raiseError( + new IllegalStateException( + "IOLocalContextStorage is not configured for use as the ContextStorageProvider. " + + s"The current storage: ${other.getClass.getName}." + ) + ) + } + } + +} diff --git a/oteljava/context-storage/src/main/scala/org/typelevel/otel4s/oteljava/IOLocalContextStorageProvider.scala b/oteljava/context-storage/src/main/scala/org/typelevel/otel4s/oteljava/IOLocalContextStorageProvider.scala new file mode 100644 index 000000000..49e9eeb2f --- /dev/null +++ b/oteljava/context-storage/src/main/scala/org/typelevel/otel4s/oteljava/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.oteljava + +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.oteljava.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/oteljava/context-storage/src/test/scala/org/typelevel/otel4s/oteljava/IOLocalContextStorageSuite.scala b/oteljava/context-storage/src/test/scala/org/typelevel/otel4s/oteljava/IOLocalContextStorageSuite.scala new file mode 100644 index 000000000..a6f6a913f --- /dev/null +++ b/oteljava/context-storage/src/test/scala/org/typelevel/otel4s/oteljava/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.oteljava + +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.oteljava.context.Context +import org.typelevel.otel4s.oteljava.context.LocalContext + +import scala.util.Using + +class IOLocalContextStorageSuite extends CatsEffectSuite { + import IOLocalContextStorageSuite._ + + private val localProvider: IO[LocalContext[IO]] = + IOLocalContextStorage.localProvider[IO].local + + 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 <- localProvider + _ <- 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) +} diff --git a/oteljava/trace/src/main/scala/org/typelevel/otel4s/oteljava/trace/SpanBuilderImpl.scala b/oteljava/trace/src/main/scala/org/typelevel/otel4s/oteljava/trace/SpanBuilderImpl.scala index 3208cec7d..719913d7d 100644 --- a/oteljava/trace/src/main/scala/org/typelevel/otel4s/oteljava/trace/SpanBuilderImpl.scala +++ b/oteljava/trace/src/main/scala/org/typelevel/otel4s/oteljava/trace/SpanBuilderImpl.scala @@ -59,7 +59,7 @@ private[oteljava] final case class SpanBuilderImpl[F[_]: Sync] private ( Resource.eval(runnerContext).flatMap(ctx => runner.start(ctx)) override def use[A](f: Span[F] => F[A]): F[A] = - resource.use { res => res.trace(f(res.span)) } + resource.use(res => res.trace(Sync[F].defer(f(res.span)))) override def use_ : F[Unit] = use(_ => Sync[F].unit) }