Skip to content

Commit

Permalink
feat: add spec compliant otel hook (#169)
Browse files Browse the repository at this point in the history
feat: add spec compliant otel hook

Signed-off-by: thiyagu06 <thiyagu103@gmail.com>
  • Loading branch information
thiyagu06 authored Dec 22, 2022
1 parent 86f1c99 commit 55a2ac2
Show file tree
Hide file tree
Showing 6 changed files with 228 additions and 23 deletions.
27 changes: 26 additions & 1 deletion hooks/README.md
Original file line number Diff line number Diff line change
@@ -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
<!-- x-release-please-start-version -->
```xml
<dependency>
<groupId>dev.openfeature.contrib.hooks</groupId>
<artifactId>otel</artifactId>
<version>0.4.0</version>
</dependency>
```
<!-- x-release-please-end-version -->

## 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());
```
17 changes: 17 additions & 0 deletions hooks/open-telemetry/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,23 @@

<dependencies>
<!-- we inherent dev.openfeature.javasdk and the test dependencies from the parent pom -->
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-sdk</artifactId>
</dependency>

</dependencies>

<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-bom</artifactId>
<version>1.20.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

</project>
Original file line number Diff line number Diff line change
@@ -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 <a href="https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/feature-flags.md">OpenTelemetry</a>
*/
public class OpenTelemetryHook {

/**
public class OpenTelemetryHook implements Hook {

private static final String EVENT_NAME = "feature_flag";

private final AttributeKey<String> flagKeyAttributeKey = AttributeKey.stringKey(EVENT_NAME + ".flag_key");

private final AttributeKey<String> providerNameAttributeKey = AttributeKey.stringKey(EVENT_NAME + ".provider_name");

private final AttributeKey<String> 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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<String> flagKeyAttributeKey = AttributeKey.stringKey("feature_flag.flag_key");

private final AttributeKey<String> providerNameAttributeKey = AttributeKey.stringKey("feature_flag.provider_name");

private final AttributeKey<String> variantAttributeKey = AttributeKey.stringKey("feature_flag.variant");

private static MockedStatic<Span> mockedSpan;

@Mock private Span span;

private HookContext<String> hookContext = HookContext.<String>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<String> details = FlagEvaluationDetails.<String>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<String> details = FlagEvaluationDetails.<String>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<String> hookContext = HookContext.<String>builder()
.flagKey("test_key")
.type(FlagValueType.STRING)
.providerMetadata(() -> "test provider")
.ctx(new MutableContext())
.defaultValue("default")
.build();
FlagEvaluationDetails<String> details = FlagEvaluationDetails.<String>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);
}

}
7 changes: 7 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,13 @@
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>4.10.0</version>
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-inline</artifactId>
Expand Down
3 changes: 2 additions & 1 deletion release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
"bump-patch-for-minor-pre-major": true,
"versioning": "default",
"extra-files": [
"pom.xml"
"pom.xml",
"README.md"
]
}
}
Expand Down

0 comments on commit 55a2ac2

Please sign in to comment.