diff --git a/hooks/README.md b/hooks/README.md
index e13d30199..2c8af2117 100644
--- a/hooks/README.md
+++ b/hooks/README.md
@@ -1,3 +1,28 @@
# OpenFeature Java Hooks
-Hooks are a mechanism whereby application developers can add arbitrary behavior to flag evaluation. They operate similarly to middleware in many web frameworks. Please see the [spec](https://github.com/open-feature/spec/blob/main/specification/sections/04-hooks.md) for more details.
+The OpenTelemetry hook for OpenFeature provides
+a [spec compliant] (https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/feature-flags.md)
+way to automatically add a feature flag
+evaluation to a span as a span event. This can be used to determine the impact a feature has on a request,
+enabling enhanced observability use cases, such as A/B testing or progressive feature releases.
+
+## Installation
+
+```xml
+
+ dev.openfeature.contrib.hooks
+ otel
+ 0.4.0
+
+```
+
+
+## Usage
+
+OpenFeature provider various ways to register hooks. The location that a hook is registered affects when the hook is
+run. It's recommended to register the `OpenTelemetryHook` globally in most situations, but it's possible to only enable
+the hook on specific clients. You should **never** register the `OpenTelemetryHook` globally and on a client.
+
+```
+ OpenFeatureAPI.getInstance().addHooks(new OpenTelemetryHook());
+```
\ No newline at end of file
diff --git a/hooks/open-telemetry/pom.xml b/hooks/open-telemetry/pom.xml
index 2cfd9194b..eec100e4a 100644
--- a/hooks/open-telemetry/pom.xml
+++ b/hooks/open-telemetry/pom.xml
@@ -26,6 +26,23 @@
+
+ io.opentelemetry
+ opentelemetry-sdk
+
+
+
+
+
+ io.opentelemetry
+ opentelemetry-bom
+ 1.20.1
+ pom
+ import
+
+
+
+
\ No newline at end of file
diff --git a/hooks/open-telemetry/src/main/java/dev/openfeature/contrib/hooks/otel/OpenTelemetryHook.java b/hooks/open-telemetry/src/main/java/dev/openfeature/contrib/hooks/otel/OpenTelemetryHook.java
index 917bb37fd..fe82b7fba 100644
--- a/hooks/open-telemetry/src/main/java/dev/openfeature/contrib/hooks/otel/OpenTelemetryHook.java
+++ b/hooks/open-telemetry/src/main/java/dev/openfeature/contrib/hooks/otel/OpenTelemetryHook.java
@@ -1,29 +1,69 @@
package dev.openfeature.contrib.hooks.otel;
-import dev.openfeature.sdk.Client;
-import dev.openfeature.sdk.NoOpProvider;
-import dev.openfeature.sdk.OpenFeatureAPI;
+import dev.openfeature.sdk.FlagEvaluationDetails;
+import dev.openfeature.sdk.Hook;
+import dev.openfeature.sdk.HookContext;
+import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.trace.Span;
-/**
- * A placeholder.
+import java.util.Map;
+
+/**
+ * The OpenTelemetry hook provides a way to automatically add a feature flag evaluation to a span as a span event.
+ * Refer to OpenTelemetry
*/
-public class OpenTelemetryHook {
-
- /**
+public class OpenTelemetryHook implements Hook {
+
+ private static final String EVENT_NAME = "feature_flag";
+
+ private final AttributeKey flagKeyAttributeKey = AttributeKey.stringKey(EVENT_NAME + ".flag_key");
+
+ private final AttributeKey providerNameAttributeKey = AttributeKey.stringKey(EVENT_NAME + ".provider_name");
+
+ private final AttributeKey variantAttributeKey = AttributeKey.stringKey(EVENT_NAME + ".variant");
+
+ /**
* Create a new OpenTelemetryHook instance.
*/
public OpenTelemetryHook() {
}
- /**
- * A test method...
+ /**
+ * Records the event in the current span after the successful flag evaluation.
*
- * @return {boolean}
+ * @param ctx Information about the particular flag evaluation
+ * @param details Information about how the flag was resolved, including any resolved values.
+ * @param hints An immutable mapping of data for users to communicate to the hooks.
*/
- public static boolean test() {
- OpenFeatureAPI.getInstance().setProvider(new NoOpProvider());
- Client client = OpenFeatureAPI.getInstance().getClient();
- return client.getBooleanValue("test2", true);
+ @Override
+ public void after(HookContext ctx, FlagEvaluationDetails details, Map hints) {
+ Span currentSpan = Span.current();
+ if (currentSpan != null) {
+ String variant = details.getVariant() != null ? details.getVariant() : String.valueOf(details.getValue());
+ Attributes attributes = Attributes.of(
+ flagKeyAttributeKey, ctx.getFlagKey(),
+ providerNameAttributeKey, ctx.getProviderMetadata().getName(),
+ variantAttributeKey, variant);
+ currentSpan.addEvent(EVENT_NAME, attributes);
+ }
}
+ /**
+ * Records the error details in the current span after the flag evaluation has processed abnormally.
+ *
+ * @param ctx Information about the particular flag evaluation
+ * @param error The exception that was thrown.
+ * @param hints An immutable mapping of data for users to communicate to the hooks.
+ */
+ @Override
+ public void error(HookContext ctx, Exception error, Map hints) {
+ Span currentSpan = Span.current();
+ if (currentSpan != null) {
+ Attributes attributes = Attributes.of(
+ flagKeyAttributeKey, ctx.getFlagKey(),
+ providerNameAttributeKey, ctx.getProviderMetadata().getName());
+ currentSpan.recordException(error, attributes);
+ }
+ }
}
diff --git a/hooks/open-telemetry/src/test/java/dev/openfeature/contrib/hooks/otel/OpenTelemetryHookTest.java b/hooks/open-telemetry/src/test/java/dev/openfeature/contrib/hooks/otel/OpenTelemetryHookTest.java
index 339128e4b..4792f2c32 100644
--- a/hooks/open-telemetry/src/test/java/dev/openfeature/contrib/hooks/otel/OpenTelemetryHookTest.java
+++ b/hooks/open-telemetry/src/test/java/dev/openfeature/contrib/hooks/otel/OpenTelemetryHookTest.java
@@ -1,15 +1,130 @@
package dev.openfeature.contrib.hooks.otel;
-import static org.assertj.core.api.Assertions.assertThat;
-
+import dev.openfeature.sdk.FlagEvaluationDetails;
+import dev.openfeature.sdk.FlagValueType;
+import dev.openfeature.sdk.HookContext;
+import dev.openfeature.sdk.MutableContext;
+import io.opentelemetry.api.common.AttributeKey;
+import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.trace.Span;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+import org.mockito.internal.matchers.Any;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+@ExtendWith(MockitoExtension.class)
class OpenTelemetryHookTest {
+ private OpenTelemetryHook openTelemetryHook = new OpenTelemetryHook();
+
+ private final AttributeKey flagKeyAttributeKey = AttributeKey.stringKey("feature_flag.flag_key");
+
+ private final AttributeKey providerNameAttributeKey = AttributeKey.stringKey("feature_flag.provider_name");
+
+ private final AttributeKey variantAttributeKey = AttributeKey.stringKey("feature_flag.variant");
+
+ private static MockedStatic mockedSpan;
+
+ @Mock private Span span;
+
+ private HookContext hookContext = HookContext.builder()
+ .flagKey("test_key")
+ .type(FlagValueType.STRING)
+ .providerMetadata(() -> "test provider")
+ .ctx(new MutableContext())
+ .defaultValue("default")
+ .build();
+
+ @BeforeAll
+ public static void init() {
+ mockedSpan = mockStatic(Span.class);
+ }
+
+ @AfterAll
+ public static void close() {
+ mockedSpan.close();
+ }
+
@Test
- @DisplayName("a simple test.")
- void test() {
- assertThat(OpenTelemetryHook.test()).isEqualTo(true);
+ @DisplayName("should add an event in span during after method execution")
+ void should_add_event_in_span_during_after_method_execution() {
+ FlagEvaluationDetails details = FlagEvaluationDetails.builder()
+ .variant("test_variant")
+ .value("variant_value")
+ .build();
+ mockedSpan.when(Span::current).thenReturn(span);
+ openTelemetryHook.after(hookContext, details, null);
+ Attributes expectedAttr = Attributes.of(flagKeyAttributeKey, "test_key",
+ providerNameAttributeKey, "test provider",
+ variantAttributeKey, "test_variant");
+ verify(span).addEvent("feature_flag", expectedAttr);
}
-}
+
+ @Test
+ @DisplayName("attribute should fallback to value field when variant is null")
+ void attribute_should_fallback_to_value_field_when_variant_is_null() {
+ FlagEvaluationDetails details = FlagEvaluationDetails.builder()
+ .value("variant_value")
+ .build();
+ mockedSpan.when(Span::current).thenReturn(span);
+ openTelemetryHook.after(hookContext, details, null);
+ Attributes expectedAttr = Attributes.of(flagKeyAttributeKey, "test_key",
+ providerNameAttributeKey, "test provider",
+ variantAttributeKey, "variant_value");
+ verify(span).addEvent("feature_flag", expectedAttr);
+ }
+
+ @Test
+ @DisplayName("should not call addEvent because there is no active span")
+ void should_not_call_add_event_when_no_active_span() {
+ HookContext hookContext = HookContext.builder()
+ .flagKey("test_key")
+ .type(FlagValueType.STRING)
+ .providerMetadata(() -> "test provider")
+ .ctx(new MutableContext())
+ .defaultValue("default")
+ .build();
+ FlagEvaluationDetails details = FlagEvaluationDetails.builder()
+ .variant(null)
+ .value("variant_value")
+ .build();
+ mockedSpan.when(Span::current).thenReturn(null);
+ openTelemetryHook.after(hookContext, details, null);
+ verifyNoInteractions(span);
+ }
+
+ @Test
+ @DisplayName("should record an exception in span during error method execution")
+ void should_record_exception_in_span_during_error_method_execution() {
+ RuntimeException runtimeException = new RuntimeException("could not resolve the flag");
+ mockedSpan.when(Span::current).thenReturn(span);
+ openTelemetryHook.error(hookContext, runtimeException, null);
+ Attributes expectedAttr = Attributes.of(flagKeyAttributeKey, "test_key",
+ providerNameAttributeKey, "test provider");
+ verify(span).recordException(runtimeException, expectedAttr);
+ }
+
+ @Test
+ @DisplayName("should not call recordException because there is no active span")
+ void should_not_call_record_exception_when_no_active_span() {
+ RuntimeException runtimeException = new RuntimeException("could not resolve the flag");
+ mockedSpan.when(Span::current).thenReturn(null);
+ openTelemetryHook.error(hookContext, runtimeException, null);
+ verifyNoInteractions(span);
+ }
+
+}
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index caacd7225..64a946c6c 100644
--- a/pom.xml
+++ b/pom.xml
@@ -131,6 +131,13 @@
test
+
+ org.mockito
+ mockito-junit-jupiter
+ 4.10.0
+ test
+
+
org.mockito
mockito-inline
diff --git a/release-please-config.json b/release-please-config.json
index 4f7efc30d..6aba5d1e0 100644
--- a/release-please-config.json
+++ b/release-please-config.json
@@ -29,7 +29,8 @@
"bump-patch-for-minor-pre-major": true,
"versioning": "default",
"extra-files": [
- "pom.xml"
+ "pom.xml",
+ "README.md"
]
}
}