Skip to content

Commit

Permalink
Add sdk-otel4s module (#38)
Browse files Browse the repository at this point in the history
- **Hide Hooks from public interface**
- **Add TraceHooks**
- **Add feature flag key attribute**
  • Loading branch information
alexcardell authored Sep 21, 2024
1 parent b2488ec commit cefad31
Show file tree
Hide file tree
Showing 12 changed files with 364 additions and 11 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,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 openfeature/provider-flipt/.jvm/target openfeature/provider-memory/.js/target flipt/sdk-server/native/target flipt/sdk-server/js/target openfeature/sdk-circe/.native/target openfeature/provider-memory/.native/target openfeature/sdk-circe/.jvm/target openfeature/provider-flipt/.native/target openfeature/sdk-circe/.js/target openfeature/sdk/.native/target openfeature/provider-memory/.jvm/target openfeature/provider-java/.jvm/target openfeature/provider-flipt/.js/target openfeature/sdk/.jvm/target openfeature/sdk/.js/target flipt/sdk-server/jvm/target project/target
run: mkdir -p openfeature/provider-flipt/.jvm/target openfeature/provider-memory/.js/target flipt/sdk-server/native/target openfeature/sdk-otel4s/.js/target flipt/sdk-server/js/target openfeature/sdk-circe/.native/target openfeature/provider-memory/.native/target openfeature/sdk-otel4s/.jvm/target openfeature/sdk-circe/.jvm/target openfeature/provider-flipt/.native/target openfeature/sdk-otel4s/.native/target openfeature/sdk-circe/.js/target openfeature/sdk/.native/target openfeature/provider-memory/.jvm/target openfeature/provider-java/.jvm/target openfeature/provider-flipt/.js/target openfeature/sdk/.jvm/target openfeature/sdk/.js/target flipt/sdk-server/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 openfeature/provider-flipt/.jvm/target openfeature/provider-memory/.js/target flipt/sdk-server/native/target flipt/sdk-server/js/target openfeature/sdk-circe/.native/target openfeature/provider-memory/.native/target openfeature/sdk-circe/.jvm/target openfeature/provider-flipt/.native/target openfeature/sdk-circe/.js/target openfeature/sdk/.native/target openfeature/provider-memory/.jvm/target openfeature/provider-java/.jvm/target openfeature/provider-flipt/.js/target openfeature/sdk/.jvm/target openfeature/sdk/.js/target flipt/sdk-server/jvm/target project/target
run: tar cf targets.tar openfeature/provider-flipt/.jvm/target openfeature/provider-memory/.js/target flipt/sdk-server/native/target openfeature/sdk-otel4s/.js/target flipt/sdk-server/js/target openfeature/sdk-circe/.native/target openfeature/provider-memory/.native/target openfeature/sdk-otel4s/.jvm/target openfeature/sdk-circe/.jvm/target openfeature/provider-flipt/.native/target openfeature/sdk-otel4s/.native/target openfeature/sdk-circe/.js/target openfeature/sdk/.native/target openfeature/provider-memory/.jvm/target openfeature/provider-java/.jvm/target openfeature/provider-flipt/.js/target openfeature/sdk/.jvm/target openfeature/sdk/.js/target flipt/sdk-server/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')
Expand Down
22 changes: 22 additions & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ lazy val projects = Seq(
`flipt-sdk-server-it`,
`openfeature-sdk`,
`openfeature-sdk-circe`,
`openfeature-sdk-otel4s`,
`openfeature-provider-memory`,
`openfeature-provider-java`,
`openfeature-provider-java-it`,
Expand Down Expand Up @@ -117,6 +118,26 @@ lazy val `openfeature-sdk-circe` = crossProject(
)
.dependsOn(`openfeature-sdk`)

lazy val `openfeature-sdk-otel4s` = crossProject(
JVMPlatform,
JSPlatform,
NativePlatform
)
.crossType(CrossType.Pure)
.in(file("openfeature/sdk-otel4s"))
.settings(commonDependencies)
.settings(
name := "openfeature-sdk-otel4s",
libraryDependencies ++= Seq(
"org.typelevel" %%% "otel4s-core-trace" % V.otel4s,
"org.typelevel" %%% "otel4s-sdk-testkit" % V.otel4s % Test
)
)
.dependsOn(
`openfeature-sdk`,
`openfeature-provider-memory` % "test->test"
)

lazy val `openfeature-provider-memory` = crossProject(
JVMPlatform,
JSPlatform,
Expand Down Expand Up @@ -217,6 +238,7 @@ lazy val docs = project
.dependsOn(
`openfeature-sdk`.jvm,
`openfeature-sdk-circe`.jvm,
`openfeature-sdk-otel4s`.jvm,
`openfeature-provider-java`.jvm,
`openfeature-provider-flipt`.jvm
)
Expand Down
40 changes: 40 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,46 @@ def program(features: FeatureClient[IO])(
Hooks are work-in-progress. All four OpenFeature [hook types](https://openfeature.dev/specification/sections/hooks)
are supported but only on the `FeatureClient` and `Provider` interfaces.

Hook types:
- BeforeHook (can optionally manipulate EvaluationContext)
- AfterHook
- ErrorHook
- FinallyHook

```scala mdoc
import cats.effect.IO
import io.cardell.openfeature.FeatureClient
import io.cardell.openfeature.BeforeHook
import io.cardell.openfeature.provider.Provider

val hook = BeforeHook[IO] { case (context, hints @ _) =>
IO.println(s"I'm about to evaluate ${context.flagKey}").as(None)
}

def providerWithHook(provider: Provider[IO]) =
provider.withHook(hook)

// and similarly for `client`
def clientWithHook(client: FeatureClient[IO]) =
client.withHook(hook)
```

### otel4s

`otel4s` trace integration is provided, offering a set of trace hooks

```scala mdoc
import cats.effect.IO
import org.typelevel.otel4s.trace.Tracer
import io.cardell.openfeature.FeatureClient
import io.cardell.openfeature.otel4s.TraceHooks

def tracedClient(
client: FeatureClient[IO]
)(implicit T: Tracer[IO]) = TraceHooks.ioLocal
.map(hooks => client.withHooks(hooks))
```

### Variants

Providers offer resolving a particular variant, using a Structure type. Typically this is JSON defined on the server side.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* 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 org.typelevel.otel4s.AttributeKey

// Shim attributes until they are moved from experimental to stable
object FeatureFlagAttributes {
val FeatureFlagKey: AttributeKey[String] = AttributeKey("feature_flag.key")

/** The name of the service provider that performs the flag evaluation.
*/
val FeatureFlagProviderName: AttributeKey[String] = AttributeKey(
"feature_flag.provider_name"
)

/** SHOULD be a semantic identifier for a value. If one is unavailable, a
* stringified version of the value can be used. <p>
* @note
* <p> A semantic identifier, commonly referred to as a variant, provides a
* means for referring to a value without including the value itself. This
* can provide additional context for understanding the meaning behind a
* value. For example, the variant `red` maybe be used for the value
* `#c05543`. <p> A stringified version of the value can be used in
* situations where a semantic identifier is unavailable. String
* representation of the value should be determined by the implementer.
*/
val FeatureFlagVariant: AttributeKey[String] = AttributeKey(
"feature_flag.variant"
)

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/*
* 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_)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/*
* 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.effect.IO
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 {

val setupProvider = MemoryProvider[IO](
Map("boolean-flag" -> FlagValue.BooleanValue(true))
)

val setupTestkit = TracesTestkit.inMemory[IO]()

test("span is applied") {
val expectedFlagResult = true
val expectedSpanName = "resolve-flag"
val expectedSpanCount = 1
val expectedSpanStatus = StatusCode.Ok

setupTestkit.use { kit =>
val setupTracer = kit.tracerProvider.tracer("name").get

setupTracer.flatMap { implicit tracer =>
for {
hooks <- TraceHooks.ioLocal
provider <- setupProvider.map(ProviderImpl[IO])
hookedProvider = hooks.foldLeft(provider)(_ withHook _)
flagResolution <- hookedProvider.resolveBooleanValue(
"boolean-flag",
false,
EvaluationContext.empty
)
flagResult = flagResolution.value
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(flagResult, expectedFlagResult)
assertEquals(spanCount, expectedSpanCount)
assertEquals(spanName, Some(expectedSpanName))
assertEquals(spanStatus, Some(expectedSpanStatus))
assertEquals(spanEnded, Some(true))
assertEquals(flagKeyAttrExists, Some(true))
}

}
}

}

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))
}
}
}

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ trait FeatureClient[F[_]] {

def withHook(hook: Hook[F]): FeatureClient[F]

def withHooks(hooks: List[Hook[F]]): FeatureClient[F] =
hooks.foldLeft(this)(_ withHook _)

def getBooleanValue(flagKey: String, default: Boolean): F[Boolean]

def getBooleanValue(
Expand Down
Loading

0 comments on commit cefad31

Please sign in to comment.