From a12deb127dc31bd91951a8c3f9e218fd68542967 Mon Sep 17 00:00:00 2001 From: Alex Cardell <29524087+alexcardell@users.noreply.github.com> Date: Sun, 6 Oct 2024 12:13:55 +0100 Subject: [PATCH] Replace TraceHooks with TracedProvider (#39) And update otel4s to 0.10 The IOLocal solution doesn't work for propagating trace context to traces created in the provider, and also doesn't provide much context for the evaluation details --- build.sbt | 3 +- docs/index.md | 21 ++- .../openfeature/otel4s/TraceHook.scala | 91 ------------- .../openfeature/otel4s/TracedProvider.scala | 126 ++++++++++++++++++ .../syntax/EvaluationProviderSyntax.scala | 37 +++++ .../openfeature/otel4s/syntax/package.scala | 19 +++ ...ookTest.scala => TracedProviderTest.scala} | 59 +------- project/Versions.scala | 2 +- 8 files changed, 203 insertions(+), 155 deletions(-) delete mode 100644 openfeature/sdk-otel4s/src/main/scala/io/cardell/openfeature/otel4s/TraceHook.scala create mode 100644 openfeature/sdk-otel4s/src/main/scala/io/cardell/openfeature/otel4s/TracedProvider.scala create mode 100644 openfeature/sdk-otel4s/src/main/scala/io/cardell/openfeature/otel4s/syntax/EvaluationProviderSyntax.scala create mode 100644 openfeature/sdk-otel4s/src/main/scala/io/cardell/openfeature/otel4s/syntax/package.scala rename openfeature/sdk-otel4s/src/test/scala/io/cardell/openfeature/otel4s/{TraceHookTest.scala => TracedProviderTest.scala} (54%) diff --git a/build.sbt b/build.sbt index 1877ba7..c7773ea 100644 --- a/build.sbt +++ b/build.sbt @@ -2,8 +2,7 @@ import build.V Global / onChangedBuildSource := ReloadOnSourceChanges -// https://typelevel.org/sbt-typelevel/faq.html#what-is-a-base-version-anyway -ThisBuild / tlBaseVersion := "0.4" // your current series x.y +ThisBuild / tlBaseVersion := "0.5" ThisBuild / organization := "io.cardell" ThisBuild / organizationName := "Alex Cardell" diff --git a/docs/index.md b/docs/index.md index 4ea14d5..6170d6c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -142,13 +142,22 @@ def clientWithHook(client: FeatureClient[IO]) = ```scala mdoc import cats.effect.IO import org.typelevel.otel4s.trace.Tracer -import io.cardell.openfeature.FeatureClient -import io.cardell.openfeature.otel4s.TraceHooks +import io.cardell.openfeature.provider.EvaluationProvider +import io.cardell.openfeature.otel4s.TracedProvider + +def traceExample( + provider: EvaluationProvider[IO] +)(implicit T: Tracer[IO]) = + new TracedProvider[IO](provider) + +// or + +import io.cardell.openfeature.otel4s.syntax._ -def tracedClient( - client: FeatureClient[IO] -)(implicit T: Tracer[IO]) = TraceHooks.ioLocal - .map(hooks => client.withHooks(hooks)) +def tracedProviderSyntax( + provider: EvaluationProvider[IO] +)(implicit T: Tracer[IO]) = + provider.withTracing ``` ### Variants diff --git a/openfeature/sdk-otel4s/src/main/scala/io/cardell/openfeature/otel4s/TraceHook.scala b/openfeature/sdk-otel4s/src/main/scala/io/cardell/openfeature/otel4s/TraceHook.scala deleted file mode 100644 index 6c0e90a..0000000 --- a/openfeature/sdk-otel4s/src/main/scala/io/cardell/openfeature/otel4s/TraceHook.scala +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Copyright 2023 Alex Cardell - * - * 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 io.cardell.openfeature.otel4s - -import cats.data.OptionT -import cats.effect.IO -import cats.effect.IOLocal -import cats.syntax.all._ -import org.typelevel.otel4s.Attributes -import org.typelevel.otel4s.trace.Span -import org.typelevel.otel4s.trace.StatusCode -import org.typelevel.otel4s.trace.Tracer - -import io.cardell.openfeature.AfterHook -import io.cardell.openfeature.BeforeHook -import io.cardell.openfeature.ErrorHook -import io.cardell.openfeature.FinallyHook -import io.cardell.openfeature.Hook - -object TraceHooks { - - def ioLocal(implicit T: Tracer[IO]): IO[List[Hook[IO]]] = IOLocal( - Option.empty[Span[IO]] - ).map(fromIOLocal) - - private def fromIOLocal( - local: IOLocal[Option[Span[IO]]] - )(implicit T: Tracer[IO]): List[Hook[IO]] = { - import FeatureFlagAttributes._ - - val before = BeforeHook[IO] { case (context, _) => - val attributes = Attributes( - FeatureFlagKey(context.flagKey) - ) - - Tracer[IO] - .span("resolve-flag", attributes) - .startUnmanaged - .flatMap(s => local.update(_ => s.some)) - .as(None) - } - - val after = AfterHook[IO] { case _ => - OptionT(local.get) - .semiflatMap { span => - for { - _ <- span.setStatus(StatusCode.Ok) - _ <- span.end - } yield () - } - .value - .void - } - - val error = ErrorHook[IO] { case (_, _, error) => - OptionT(local.get) - .semiflatMap { span => - for { - _ <- span.setStatus(StatusCode.Error) - _ <- span.recordException(error) - } yield () - } - .value - .void - } - - val finally_ = FinallyHook[IO] { case _ => - OptionT(local.get) - .semiflatMap(_.end) - .value - .void - } - - List(before, after, error, finally_) - } - -} diff --git a/openfeature/sdk-otel4s/src/main/scala/io/cardell/openfeature/otel4s/TracedProvider.scala b/openfeature/sdk-otel4s/src/main/scala/io/cardell/openfeature/otel4s/TracedProvider.scala new file mode 100644 index 0000000..5d212dd --- /dev/null +++ b/openfeature/sdk-otel4s/src/main/scala/io/cardell/openfeature/otel4s/TracedProvider.scala @@ -0,0 +1,126 @@ +/* + * Copyright 2023 Alex Cardell + * + * 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 io.cardell.openfeature.otel4s + +import cats.MonadThrow +import cats.syntax.all._ +import org.typelevel.otel4s.Attributes +import org.typelevel.otel4s.trace.StatusCode +import org.typelevel.otel4s.trace.Tracer + +import io.cardell.openfeature.EvaluationContext +import io.cardell.openfeature.StructureCodec +import io.cardell.openfeature.otel4s.FeatureFlagAttributes.FeatureFlagKey +import io.cardell.openfeature.otel4s.FeatureFlagAttributes.FeatureFlagProviderName +import io.cardell.openfeature.otel4s.FeatureFlagAttributes.FeatureFlagVariant +import io.cardell.openfeature.provider.EvaluationProvider +import io.cardell.openfeature.provider.ProviderMetadata +import io.cardell.openfeature.provider.ResolutionDetails + +class TracedProvider[F[_]: Tracer: MonadThrow]( + provider: EvaluationProvider[F] +) extends EvaluationProvider[F] { + + override def metadata: ProviderMetadata = provider.metadata + + override def resolveBooleanValue( + flagKey: String, + defaultValue: Boolean, + context: EvaluationContext + ): F[ResolutionDetails[Boolean]] = + trace("boolean", flagKey)( + provider.resolveBooleanValue( + flagKey, + defaultValue, + context + ) + ) + + override def resolveStringValue( + flagKey: String, + defaultValue: String, + context: EvaluationContext + ): F[ResolutionDetails[String]] = + trace("string", flagKey)( + provider.resolveStringValue( + flagKey, + defaultValue, + context + ) + ) + + override def resolveIntValue( + flagKey: String, + defaultValue: Int, + context: EvaluationContext + ): F[ResolutionDetails[Int]] = + trace("int", flagKey)( + provider.resolveIntValue( + flagKey, + defaultValue, + context + ) + ) + + override def resolveDoubleValue( + flagKey: String, + defaultValue: Double, + context: EvaluationContext + ): F[ResolutionDetails[Double]] = + trace("double", flagKey)( + provider.resolveDoubleValue( + flagKey, + defaultValue, + context + ) + ) + + override def resolveStructureValue[A: StructureCodec]( + flagKey: String, + defaultValue: A, + context: EvaluationContext + ): F[ResolutionDetails[A]] = + trace("structure", flagKey)( + provider.resolveStructureValue( + flagKey, + defaultValue, + context + ) + ) + + private def flagAttributes(flagKey: String): Attributes = Attributes( + FeatureFlagKey(flagKey), + FeatureFlagProviderName(metadata.name) + ) + + private def variantAttributes(maybeVariant: Option[String]): Attributes = + Attributes.empty.concat(FeatureFlagVariant.maybe(maybeVariant)) + + private def trace[A](flagType: String, flagKey: String)( + fa: F[ResolutionDetails[A]] + ): F[ResolutionDetails[A]] = Tracer[F] + .span(s"evaluate-${flagType}-flag") + .use { span => + for { + _ <- span.addAttributes(flagAttributes(flagKey)) + res <- fa.onError(span.recordException(_)) + _ <- span.addAttributes(variantAttributes(res.variant)) + _ <- span.setStatus(StatusCode.Ok) + } yield res + } + +} diff --git a/openfeature/sdk-otel4s/src/main/scala/io/cardell/openfeature/otel4s/syntax/EvaluationProviderSyntax.scala b/openfeature/sdk-otel4s/src/main/scala/io/cardell/openfeature/otel4s/syntax/EvaluationProviderSyntax.scala new file mode 100644 index 0000000..429237b --- /dev/null +++ b/openfeature/sdk-otel4s/src/main/scala/io/cardell/openfeature/otel4s/syntax/EvaluationProviderSyntax.scala @@ -0,0 +1,37 @@ +/* + * Copyright 2023 Alex Cardell + * + * 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 io.cardell.openfeature.otel4s.syntax + +import cats.MonadThrow +import org.typelevel.otel4s.trace.Tracer + +import io.cardell.openfeature.otel4s.TracedProvider +import io.cardell.openfeature.provider.EvaluationProvider + +class EvaluationProviderOps[F[_]: Tracer: MonadThrow]( + provider: EvaluationProvider[F] +) { + def withTracing: EvaluationProvider[F] = new TracedProvider[F](provider) +} + +trait EvaluationProviderSyntax { + + implicit def ops[F[_]: Tracer: MonadThrow]( + provider: EvaluationProvider[F] + ): EvaluationProviderOps[F] = new EvaluationProviderOps[F](provider) + +} diff --git a/openfeature/sdk-otel4s/src/main/scala/io/cardell/openfeature/otel4s/syntax/package.scala b/openfeature/sdk-otel4s/src/main/scala/io/cardell/openfeature/otel4s/syntax/package.scala new file mode 100644 index 0000000..5391aba --- /dev/null +++ b/openfeature/sdk-otel4s/src/main/scala/io/cardell/openfeature/otel4s/syntax/package.scala @@ -0,0 +1,19 @@ +/* + * Copyright 2023 Alex Cardell + * + * 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 io.cardell.openfeature.otel4s + +package object syntax extends EvaluationProviderSyntax diff --git a/openfeature/sdk-otel4s/src/test/scala/io/cardell/openfeature/otel4s/TraceHookTest.scala b/openfeature/sdk-otel4s/src/test/scala/io/cardell/openfeature/otel4s/TracedProviderTest.scala similarity index 54% rename from openfeature/sdk-otel4s/src/test/scala/io/cardell/openfeature/otel4s/TraceHookTest.scala rename to openfeature/sdk-otel4s/src/test/scala/io/cardell/openfeature/otel4s/TracedProviderTest.scala index 99e38a2..7668abc 100644 --- a/openfeature/sdk-otel4s/src/test/scala/io/cardell/openfeature/otel4s/TraceHookTest.scala +++ b/openfeature/sdk-otel4s/src/test/scala/io/cardell/openfeature/otel4s/TracedProviderTest.scala @@ -21,13 +21,12 @@ import munit.CatsEffectSuite import org.typelevel.otel4s.sdk.testkit.trace.TracesTestkit import org.typelevel.otel4s.trace.StatusCode -import io.cardell.openfeature.AfterHook import io.cardell.openfeature.EvaluationContext import io.cardell.openfeature.FlagValue import io.cardell.openfeature.provider.ProviderImpl import io.cardell.openfeature.provider.memory.MemoryProvider -class TraceHookTest extends CatsEffectSuite { +class TracedProviderTest extends CatsEffectSuite { val setupProvider = MemoryProvider[IO]( Map("boolean-flag" -> FlagValue.BooleanValue(true)) @@ -37,7 +36,7 @@ class TraceHookTest extends CatsEffectSuite { test("span is applied") { val expectedFlagResult = true - val expectedSpanName = "resolve-flag" + val expectedSpanName = "evaluate-boolean-flag" val expectedSpanCount = 1 val expectedSpanStatus = StatusCode.Ok @@ -46,10 +45,9 @@ class TraceHookTest extends CatsEffectSuite { setupTracer.flatMap { implicit tracer => for { - hooks <- TraceHooks.ioLocal provider <- setupProvider.map(ProviderImpl[IO]) - hookedProvider = hooks.foldLeft(provider)(_ withHook _) - flagResolution <- hookedProvider.resolveBooleanValue( + tracedProvider = new TracedProvider[IO](provider) + flagResolution <- tracedProvider.resolveBooleanValue( "boolean-flag", false, EvaluationContext.empty @@ -79,53 +77,4 @@ class TraceHookTest extends CatsEffectSuite { } - test("span is recorded as exception when exception thrown in after hook") { - val expectedSpanName = "resolve-flag" - val expectedSpanCount = 1 - val expectedSpanStatus = StatusCode.Error - - val throwingHook = AfterHook[IO] { case _ => - IO.raiseError(new Throwable("throwing hook")) - } - - setupTestkit.use { kit => - val setupTracer = kit.tracerProvider.tracer("name").get - - setupTracer.flatMap { implicit tracer => - for { - traceHooks <- TraceHooks.ioLocal - (before, others) = (traceHooks.head, traceHooks.tail) - hooks = List(before, throwingHook) ++ others - provider <- setupProvider.map(ProviderImpl[IO]) - hookedProvider = hooks.foldLeft(provider)(_ withHook _) - _ <- - hookedProvider - .resolveBooleanValue( - "boolean-flag", - false, - EvaluationContext.empty - ) - .attempt - spans <- kit.finishedSpans - spanCount = spans.size - headSpan = spans.headOption - spanName = headSpan.map(_.name) - spanStatus = headSpan.map(_.status.status) - spanEnded = headSpan.map(_.hasEnded) - spanAttrs = headSpan.map(_.attributes.elements) - flagKeyAttrExists = spanAttrs.map( - _.exists(_ == FeatureFlagAttributes.FeatureFlagKey("boolean-flag")) - ) - } yield { - assertEquals(spanCount, expectedSpanCount) - assertEquals(spanName, Some(expectedSpanName)) - assertEquals(spanStatus, Some(expectedSpanStatus)) - assertEquals(spanEnded, Some(true)) - assertEquals(flagKeyAttrExists, Some(true)) - } - } - } - - } - } diff --git a/project/Versions.scala b/project/Versions.scala index f728f7a..4c1498b 100644 --- a/project/Versions.scala +++ b/project/Versions.scala @@ -5,7 +5,7 @@ object V { val catsEffect = "3.5.4" val http4s = "0.23.26" val circe = "0.14.7" - val otel4s = "0.9.0" + val otel4s = "0.10.0" val munit = "1.0.0-RC1" val munitCatsEffect = "2.0.0-M5"