Skip to content

Commit fe4c153

Browse files
committed
Add flag evaluation metrics via OTel counter and OpenFeature Hook
Record a `feature_flag.evaluations` OTel counter on every flag evaluation using an OpenFeature `finallyAfter` hook. The hook captures all evaluation paths including type mismatches that occur above the provider level. Attributes: feature_flag.key, feature_flag.result.variant, feature_flag.result.reason, error.type (on error), feature_flag.result.allocation_key (when present). Counter is a no-op when DD_METRICS_OTEL_ENABLED is false or opentelemetry-api is absent from the classpath.
1 parent 755e6b7 commit fe4c153

File tree

7 files changed

+443
-0
lines changed

7 files changed

+443
-0
lines changed

products/feature-flagging/feature-flagging-api/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,10 @@ dependencies {
4444
api("dev.openfeature:sdk:1.20.1")
4545

4646
compileOnly(project(":products:feature-flagging:feature-flagging-bootstrap"))
47+
compileOnly("io.opentelemetry:opentelemetry-api:1.47.0")
4748

4849
testImplementation(project(":products:feature-flagging:feature-flagging-bootstrap"))
50+
testImplementation("io.opentelemetry:opentelemetry-api:1.47.0")
4951
testImplementation(libs.bundles.junit5)
5052
testImplementation(libs.bundles.mockito)
5153
testImplementation(libs.moshi)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package datadog.trace.api.openfeature;
2+
3+
import dev.openfeature.sdk.FlagEvaluationDetails;
4+
import dev.openfeature.sdk.Hook;
5+
import dev.openfeature.sdk.HookContext;
6+
import dev.openfeature.sdk.ImmutableMetadata;
7+
import java.util.Map;
8+
9+
class FlagEvalHook implements Hook<Object> {
10+
11+
private final FlagEvalMetrics metrics;
12+
13+
FlagEvalHook(FlagEvalMetrics metrics) {
14+
this.metrics = metrics;
15+
}
16+
17+
@Override
18+
public void finallyAfter(
19+
HookContext<Object> ctx, FlagEvaluationDetails<Object> details, Map<String, Object> hints) {
20+
if (metrics == null) {
21+
return;
22+
}
23+
try {
24+
String flagKey = details.getFlagKey();
25+
String variant = details.getVariant();
26+
String reason = details.getReason();
27+
dev.openfeature.sdk.ErrorCode errorCode = details.getErrorCode();
28+
29+
String allocationKey = null;
30+
ImmutableMetadata metadata = details.getFlagMetadata();
31+
if (metadata != null) {
32+
allocationKey = metadata.getString("allocationKey");
33+
}
34+
35+
metrics.record(flagKey, variant, reason, errorCode, allocationKey);
36+
} catch (Exception e) {
37+
// Never let metrics recording break flag evaluation
38+
}
39+
}
40+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package datadog.trace.api.openfeature;
2+
3+
import dev.openfeature.sdk.ErrorCode;
4+
import io.opentelemetry.api.GlobalOpenTelemetry;
5+
import io.opentelemetry.api.common.AttributeKey;
6+
import io.opentelemetry.api.common.Attributes;
7+
import io.opentelemetry.api.common.AttributesBuilder;
8+
import io.opentelemetry.api.metrics.LongCounter;
9+
import io.opentelemetry.api.metrics.Meter;
10+
11+
class FlagEvalMetrics {
12+
13+
private static final String METER_NAME = "ddtrace.openfeature";
14+
private static final String METRIC_NAME = "feature_flag.evaluations";
15+
private static final String METRIC_UNIT = "{evaluation}";
16+
private static final String METRIC_DESC = "Number of feature flag evaluations";
17+
18+
private static final AttributeKey<String> ATTR_FLAG_KEY =
19+
AttributeKey.stringKey("feature_flag.key");
20+
private static final AttributeKey<String> ATTR_VARIANT =
21+
AttributeKey.stringKey("feature_flag.result.variant");
22+
private static final AttributeKey<String> ATTR_REASON =
23+
AttributeKey.stringKey("feature_flag.result.reason");
24+
private static final AttributeKey<String> ATTR_ERROR_TYPE = AttributeKey.stringKey("error.type");
25+
private static final AttributeKey<String> ATTR_ALLOCATION_KEY =
26+
AttributeKey.stringKey("feature_flag.result.allocation_key");
27+
28+
private volatile LongCounter counter;
29+
30+
FlagEvalMetrics() {
31+
try {
32+
Meter meter = GlobalOpenTelemetry.getMeterProvider().meterBuilder(METER_NAME).build();
33+
counter =
34+
meter
35+
.counterBuilder(METRIC_NAME)
36+
.setUnit(METRIC_UNIT)
37+
.setDescription(METRIC_DESC)
38+
.build();
39+
} catch (NoClassDefFoundError | Exception e) {
40+
// OTel API not on classpath or initialization failed — counter stays null (no-op)
41+
counter = null;
42+
}
43+
}
44+
45+
/** Package-private constructor for testing with a mock counter. */
46+
FlagEvalMetrics(LongCounter counter) {
47+
this.counter = counter;
48+
}
49+
50+
void record(
51+
String flagKey, String variant, String reason, ErrorCode errorCode, String allocationKey) {
52+
LongCounter c = counter;
53+
if (c == null) {
54+
return;
55+
}
56+
try {
57+
AttributesBuilder builder =
58+
Attributes.builder()
59+
.put(ATTR_FLAG_KEY, flagKey)
60+
.put(ATTR_VARIANT, variant != null ? variant : "")
61+
.put(ATTR_REASON, reason != null ? reason.toLowerCase() : "unknown");
62+
63+
if (errorCode != null) {
64+
builder.put(ATTR_ERROR_TYPE, errorCode.name().toLowerCase());
65+
}
66+
67+
if (allocationKey != null && !allocationKey.isEmpty()) {
68+
builder.put(ATTR_ALLOCATION_KEY, allocationKey);
69+
}
70+
71+
c.add(1, builder.build());
72+
} catch (Exception e) {
73+
// Never let metrics recording break flag evaluation
74+
}
75+
}
76+
77+
void shutdown() {
78+
counter = null;
79+
}
80+
}

products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import de.thetaphi.forbiddenapis.SuppressForbidden;
66
import dev.openfeature.sdk.EvaluationContext;
77
import dev.openfeature.sdk.EventProvider;
8+
import dev.openfeature.sdk.Hook;
89
import dev.openfeature.sdk.Metadata;
910
import dev.openfeature.sdk.ProviderEvaluation;
1011
import dev.openfeature.sdk.ProviderEvent;
@@ -14,6 +15,8 @@
1415
import dev.openfeature.sdk.exceptions.OpenFeatureError;
1516
import dev.openfeature.sdk.exceptions.ProviderNotReadyError;
1617
import java.lang.reflect.Constructor;
18+
import java.util.Collections;
19+
import java.util.List;
1720
import java.util.concurrent.TimeUnit;
1821
import java.util.concurrent.atomic.AtomicBoolean;
1922

@@ -25,6 +28,8 @@ public class Provider extends EventProvider implements Metadata {
2528
private volatile Evaluator evaluator;
2629
private final Options options;
2730
private final AtomicBoolean initialized = new AtomicBoolean(false);
31+
private final FlagEvalMetrics flagEvalMetrics;
32+
private final FlagEvalHook flagEvalHook;
2833

2934
public Provider() {
3035
this(DEFAULT_OPTIONS, null);
@@ -37,6 +42,8 @@ public Provider(final Options options) {
3742
Provider(final Options options, final Evaluator evaluator) {
3843
this.options = options;
3944
this.evaluator = evaluator;
45+
this.flagEvalMetrics = new FlagEvalMetrics();
46+
this.flagEvalHook = new FlagEvalHook(flagEvalMetrics);
4047
}
4148

4249
@Override
@@ -77,8 +84,14 @@ private Evaluator buildEvaluator() throws Exception {
7784
return (Evaluator) ctor.newInstance((Runnable) this::onConfigurationChange);
7885
}
7986

87+
@Override
88+
public List<Hook> getProviderHooks() {
89+
return Collections.singletonList(flagEvalHook);
90+
}
91+
8092
@Override
8193
public void shutdown() {
94+
flagEvalMetrics.shutdown();
8295
if (evaluator != null) {
8396
evaluator.shutdown();
8497
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package datadog.trace.api.openfeature;
2+
3+
import static org.mockito.ArgumentMatchers.eq;
4+
import static org.mockito.ArgumentMatchers.isNull;
5+
import static org.mockito.Mockito.mock;
6+
import static org.mockito.Mockito.verify;
7+
import static org.mockito.Mockito.verifyNoInteractions;
8+
9+
import dev.openfeature.sdk.ErrorCode;
10+
import dev.openfeature.sdk.FlagEvaluationDetails;
11+
import dev.openfeature.sdk.ImmutableMetadata;
12+
import dev.openfeature.sdk.Reason;
13+
import java.util.Collections;
14+
import org.junit.jupiter.api.Test;
15+
16+
class FlagEvalHookTest {
17+
18+
@Test
19+
void finallyAfterRecordsBasicEvaluation() {
20+
FlagEvalMetrics metrics = mock(FlagEvalMetrics.class);
21+
FlagEvalHook hook = new FlagEvalHook(metrics);
22+
23+
FlagEvaluationDetails<Object> details =
24+
FlagEvaluationDetails.<Object>builder()
25+
.flagKey("my-flag")
26+
.value("on-value")
27+
.variant("on")
28+
.reason(Reason.TARGETING_MATCH.name())
29+
.flagMetadata(
30+
ImmutableMetadata.builder().addString("allocationKey", "default-alloc").build())
31+
.build();
32+
33+
hook.finallyAfter(null, details, Collections.emptyMap());
34+
35+
verify(metrics)
36+
.record(
37+
eq("my-flag"),
38+
eq("on"),
39+
eq(Reason.TARGETING_MATCH.name()),
40+
isNull(),
41+
eq("default-alloc"));
42+
}
43+
44+
@Test
45+
void finallyAfterRecordsErrorEvaluation() {
46+
FlagEvalMetrics metrics = mock(FlagEvalMetrics.class);
47+
FlagEvalHook hook = new FlagEvalHook(metrics);
48+
49+
FlagEvaluationDetails<Object> details =
50+
FlagEvaluationDetails.<Object>builder()
51+
.flagKey("missing-flag")
52+
.value("default")
53+
.reason(Reason.ERROR.name())
54+
.errorCode(ErrorCode.FLAG_NOT_FOUND)
55+
.build();
56+
57+
hook.finallyAfter(null, details, Collections.emptyMap());
58+
59+
verify(metrics)
60+
.record(
61+
eq("missing-flag"),
62+
isNull(),
63+
eq(Reason.ERROR.name()),
64+
eq(ErrorCode.FLAG_NOT_FOUND),
65+
isNull());
66+
}
67+
68+
@Test
69+
void finallyAfterHandlesNullFlagMetadata() {
70+
FlagEvalMetrics metrics = mock(FlagEvalMetrics.class);
71+
FlagEvalHook hook = new FlagEvalHook(metrics);
72+
73+
FlagEvaluationDetails<Object> details =
74+
FlagEvaluationDetails.<Object>builder()
75+
.flagKey("my-flag")
76+
.value(true)
77+
.variant("on")
78+
.reason(Reason.TARGETING_MATCH.name())
79+
.build();
80+
81+
hook.finallyAfter(null, details, Collections.emptyMap());
82+
83+
verify(metrics)
84+
.record(eq("my-flag"), eq("on"), eq(Reason.TARGETING_MATCH.name()), isNull(), isNull());
85+
}
86+
87+
@Test
88+
void finallyAfterHandlesNullVariantAndReason() {
89+
FlagEvalMetrics metrics = mock(FlagEvalMetrics.class);
90+
FlagEvalHook hook = new FlagEvalHook(metrics);
91+
92+
FlagEvaluationDetails<Object> details =
93+
FlagEvaluationDetails.<Object>builder().flagKey("my-flag").value("default").build();
94+
95+
hook.finallyAfter(null, details, Collections.emptyMap());
96+
97+
verify(metrics).record(eq("my-flag"), isNull(), isNull(), isNull(), isNull());
98+
}
99+
100+
@Test
101+
void finallyAfterNeverThrows() {
102+
FlagEvalMetrics metrics = mock(FlagEvalMetrics.class);
103+
FlagEvalHook hook = new FlagEvalHook(metrics);
104+
105+
// Should not throw even with completely null inputs
106+
hook.finallyAfter(null, null, null);
107+
108+
verifyNoInteractions(metrics);
109+
}
110+
111+
@Test
112+
void finallyAfterIsNoOpWhenMetricsIsNull() {
113+
FlagEvalHook hook = new FlagEvalHook(null);
114+
115+
FlagEvaluationDetails<Object> details =
116+
FlagEvaluationDetails.<Object>builder()
117+
.flagKey("my-flag")
118+
.value(true)
119+
.variant("on")
120+
.reason(Reason.TARGETING_MATCH.name())
121+
.build();
122+
123+
// Should not throw
124+
hook.finallyAfter(null, details, Collections.emptyMap());
125+
}
126+
}

0 commit comments

Comments
 (0)