Skip to content

Commit 55a2ac2

Browse files
authored
feat: add spec compliant otel hook (#169)
feat: add spec compliant otel hook Signed-off-by: thiyagu06 <[email protected]>
1 parent 86f1c99 commit 55a2ac2

File tree

6 files changed

+228
-23
lines changed

6 files changed

+228
-23
lines changed

hooks/README.md

+26-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,28 @@
11
# OpenFeature Java Hooks
22

3-
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.
3+
The OpenTelemetry hook for OpenFeature provides
4+
a [spec compliant] (https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/feature-flags.md)
5+
way to automatically add a feature flag
6+
evaluation to a span as a span event. This can be used to determine the impact a feature has on a request,
7+
enabling enhanced observability use cases, such as A/B testing or progressive feature releases.
8+
9+
## Installation
10+
<!-- x-release-please-start-version -->
11+
```xml
12+
<dependency>
13+
<groupId>dev.openfeature.contrib.hooks</groupId>
14+
<artifactId>otel</artifactId>
15+
<version>0.4.0</version>
16+
</dependency>
17+
```
18+
<!-- x-release-please-end-version -->
19+
20+
## Usage
21+
22+
OpenFeature provider various ways to register hooks. The location that a hook is registered affects when the hook is
23+
run. It's recommended to register the `OpenTelemetryHook` globally in most situations, but it's possible to only enable
24+
the hook on specific clients. You should **never** register the `OpenTelemetryHook` globally and on a client.
25+
26+
```
27+
OpenFeatureAPI.getInstance().addHooks(new OpenTelemetryHook());
28+
```

hooks/open-telemetry/pom.xml

+17
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,23 @@
2626

2727
<dependencies>
2828
<!-- we inherent dev.openfeature.javasdk and the test dependencies from the parent pom -->
29+
<dependency>
30+
<groupId>io.opentelemetry</groupId>
31+
<artifactId>opentelemetry-sdk</artifactId>
32+
</dependency>
33+
2934
</dependencies>
3035

36+
<dependencyManagement>
37+
<dependencies>
38+
<dependency>
39+
<groupId>io.opentelemetry</groupId>
40+
<artifactId>opentelemetry-bom</artifactId>
41+
<version>1.20.1</version>
42+
<type>pom</type>
43+
<scope>import</scope>
44+
</dependency>
45+
</dependencies>
46+
</dependencyManagement>
47+
3148
</project>
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,69 @@
11
package dev.openfeature.contrib.hooks.otel;
22

3-
import dev.openfeature.sdk.Client;
4-
import dev.openfeature.sdk.NoOpProvider;
5-
import dev.openfeature.sdk.OpenFeatureAPI;
3+
import dev.openfeature.sdk.FlagEvaluationDetails;
4+
import dev.openfeature.sdk.Hook;
5+
import dev.openfeature.sdk.HookContext;
6+
import io.opentelemetry.api.common.AttributeKey;
7+
import io.opentelemetry.api.common.Attributes;
8+
import io.opentelemetry.api.trace.Span;
69

7-
/**
8-
* A placeholder.
10+
import java.util.Map;
11+
12+
/**
13+
* The OpenTelemetry hook provides a way to automatically add a feature flag evaluation to a span as a span event.
14+
* Refer to <a href="https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/feature-flags.md">OpenTelemetry</a>
915
*/
10-
public class OpenTelemetryHook {
11-
12-
/**
16+
public class OpenTelemetryHook implements Hook {
17+
18+
private static final String EVENT_NAME = "feature_flag";
19+
20+
private final AttributeKey<String> flagKeyAttributeKey = AttributeKey.stringKey(EVENT_NAME + ".flag_key");
21+
22+
private final AttributeKey<String> providerNameAttributeKey = AttributeKey.stringKey(EVENT_NAME + ".provider_name");
23+
24+
private final AttributeKey<String> variantAttributeKey = AttributeKey.stringKey(EVENT_NAME + ".variant");
25+
26+
/**
1327
* Create a new OpenTelemetryHook instance.
1428
*/
1529
public OpenTelemetryHook() {
1630
}
1731

18-
/**
19-
* A test method...
32+
/**
33+
* Records the event in the current span after the successful flag evaluation.
2034
*
21-
* @return {boolean}
35+
* @param ctx Information about the particular flag evaluation
36+
* @param details Information about how the flag was resolved, including any resolved values.
37+
* @param hints An immutable mapping of data for users to communicate to the hooks.
2238
*/
23-
public static boolean test() {
24-
OpenFeatureAPI.getInstance().setProvider(new NoOpProvider());
25-
Client client = OpenFeatureAPI.getInstance().getClient();
26-
return client.getBooleanValue("test2", true);
39+
@Override
40+
public void after(HookContext ctx, FlagEvaluationDetails details, Map hints) {
41+
Span currentSpan = Span.current();
42+
if (currentSpan != null) {
43+
String variant = details.getVariant() != null ? details.getVariant() : String.valueOf(details.getValue());
44+
Attributes attributes = Attributes.of(
45+
flagKeyAttributeKey, ctx.getFlagKey(),
46+
providerNameAttributeKey, ctx.getProviderMetadata().getName(),
47+
variantAttributeKey, variant);
48+
currentSpan.addEvent(EVENT_NAME, attributes);
49+
}
2750
}
2851

52+
/**
53+
* Records the error details in the current span after the flag evaluation has processed abnormally.
54+
*
55+
* @param ctx Information about the particular flag evaluation
56+
* @param error The exception that was thrown.
57+
* @param hints An immutable mapping of data for users to communicate to the hooks.
58+
*/
59+
@Override
60+
public void error(HookContext ctx, Exception error, Map hints) {
61+
Span currentSpan = Span.current();
62+
if (currentSpan != null) {
63+
Attributes attributes = Attributes.of(
64+
flagKeyAttributeKey, ctx.getFlagKey(),
65+
providerNameAttributeKey, ctx.getProviderMetadata().getName());
66+
currentSpan.recordException(error, attributes);
67+
}
68+
}
2969
}
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,130 @@
11
package dev.openfeature.contrib.hooks.otel;
22

3-
import static org.assertj.core.api.Assertions.assertThat;
4-
3+
import dev.openfeature.sdk.FlagEvaluationDetails;
4+
import dev.openfeature.sdk.FlagValueType;
5+
import dev.openfeature.sdk.HookContext;
6+
import dev.openfeature.sdk.MutableContext;
7+
import io.opentelemetry.api.common.AttributeKey;
8+
import io.opentelemetry.api.common.Attributes;
9+
import io.opentelemetry.api.trace.Span;
10+
import org.junit.jupiter.api.AfterAll;
11+
import org.junit.jupiter.api.BeforeAll;
512
import org.junit.jupiter.api.DisplayName;
613
import org.junit.jupiter.api.Test;
14+
import org.junit.jupiter.api.extension.ExtendWith;
15+
import org.mockito.Mock;
16+
import org.mockito.MockedStatic;
17+
import org.mockito.Mockito;
18+
import org.mockito.MockitoAnnotations;
19+
import org.mockito.internal.matchers.Any;
20+
import org.mockito.junit.jupiter.MockitoExtension;
21+
22+
import static org.mockito.ArgumentMatchers.any;
23+
import static org.mockito.ArgumentMatchers.anyString;
24+
import static org.mockito.Mockito.mock;
25+
import static org.mockito.Mockito.mockStatic;
26+
import static org.mockito.Mockito.verify;
27+
import static org.mockito.Mockito.verifyNoInteractions;
728

29+
@ExtendWith(MockitoExtension.class)
830
class OpenTelemetryHookTest {
931

32+
private OpenTelemetryHook openTelemetryHook = new OpenTelemetryHook();
33+
34+
private final AttributeKey<String> flagKeyAttributeKey = AttributeKey.stringKey("feature_flag.flag_key");
35+
36+
private final AttributeKey<String> providerNameAttributeKey = AttributeKey.stringKey("feature_flag.provider_name");
37+
38+
private final AttributeKey<String> variantAttributeKey = AttributeKey.stringKey("feature_flag.variant");
39+
40+
private static MockedStatic<Span> mockedSpan;
41+
42+
@Mock private Span span;
43+
44+
private HookContext<String> hookContext = HookContext.<String>builder()
45+
.flagKey("test_key")
46+
.type(FlagValueType.STRING)
47+
.providerMetadata(() -> "test provider")
48+
.ctx(new MutableContext())
49+
.defaultValue("default")
50+
.build();
51+
52+
@BeforeAll
53+
public static void init() {
54+
mockedSpan = mockStatic(Span.class);
55+
}
56+
57+
@AfterAll
58+
public static void close() {
59+
mockedSpan.close();
60+
}
61+
1062
@Test
11-
@DisplayName("a simple test.")
12-
void test() {
13-
assertThat(OpenTelemetryHook.test()).isEqualTo(true);
63+
@DisplayName("should add an event in span during after method execution")
64+
void should_add_event_in_span_during_after_method_execution() {
65+
FlagEvaluationDetails<String> details = FlagEvaluationDetails.<String>builder()
66+
.variant("test_variant")
67+
.value("variant_value")
68+
.build();
69+
mockedSpan.when(Span::current).thenReturn(span);
70+
openTelemetryHook.after(hookContext, details, null);
71+
Attributes expectedAttr = Attributes.of(flagKeyAttributeKey, "test_key",
72+
providerNameAttributeKey, "test provider",
73+
variantAttributeKey, "test_variant");
74+
verify(span).addEvent("feature_flag", expectedAttr);
1475
}
15-
}
76+
77+
@Test
78+
@DisplayName("attribute should fallback to value field when variant is null")
79+
void attribute_should_fallback_to_value_field_when_variant_is_null() {
80+
FlagEvaluationDetails<String> details = FlagEvaluationDetails.<String>builder()
81+
.value("variant_value")
82+
.build();
83+
mockedSpan.when(Span::current).thenReturn(span);
84+
openTelemetryHook.after(hookContext, details, null);
85+
Attributes expectedAttr = Attributes.of(flagKeyAttributeKey, "test_key",
86+
providerNameAttributeKey, "test provider",
87+
variantAttributeKey, "variant_value");
88+
verify(span).addEvent("feature_flag", expectedAttr);
89+
}
90+
91+
@Test
92+
@DisplayName("should not call addEvent because there is no active span")
93+
void should_not_call_add_event_when_no_active_span() {
94+
HookContext<String> hookContext = HookContext.<String>builder()
95+
.flagKey("test_key")
96+
.type(FlagValueType.STRING)
97+
.providerMetadata(() -> "test provider")
98+
.ctx(new MutableContext())
99+
.defaultValue("default")
100+
.build();
101+
FlagEvaluationDetails<String> details = FlagEvaluationDetails.<String>builder()
102+
.variant(null)
103+
.value("variant_value")
104+
.build();
105+
mockedSpan.when(Span::current).thenReturn(null);
106+
openTelemetryHook.after(hookContext, details, null);
107+
verifyNoInteractions(span);
108+
}
109+
110+
@Test
111+
@DisplayName("should record an exception in span during error method execution")
112+
void should_record_exception_in_span_during_error_method_execution() {
113+
RuntimeException runtimeException = new RuntimeException("could not resolve the flag");
114+
mockedSpan.when(Span::current).thenReturn(span);
115+
openTelemetryHook.error(hookContext, runtimeException, null);
116+
Attributes expectedAttr = Attributes.of(flagKeyAttributeKey, "test_key",
117+
providerNameAttributeKey, "test provider");
118+
verify(span).recordException(runtimeException, expectedAttr);
119+
}
120+
121+
@Test
122+
@DisplayName("should not call recordException because there is no active span")
123+
void should_not_call_record_exception_when_no_active_span() {
124+
RuntimeException runtimeException = new RuntimeException("could not resolve the flag");
125+
mockedSpan.when(Span::current).thenReturn(null);
126+
openTelemetryHook.error(hookContext, runtimeException, null);
127+
verifyNoInteractions(span);
128+
}
129+
130+
}

pom.xml

+7
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,13 @@
131131
<scope>test</scope>
132132
</dependency>
133133

134+
<dependency>
135+
<groupId>org.mockito</groupId>
136+
<artifactId>mockito-junit-jupiter</artifactId>
137+
<version>4.10.0</version>
138+
<scope>test</scope>
139+
</dependency>
140+
134141
<dependency>
135142
<groupId>org.mockito</groupId>
136143
<artifactId>mockito-inline</artifactId>

release-please-config.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
"bump-patch-for-minor-pre-major": true,
3030
"versioning": "default",
3131
"extra-files": [
32-
"pom.xml"
32+
"pom.xml",
33+
"README.md"
3334
]
3435
}
3536
}

0 commit comments

Comments
 (0)