From 04ae0fb478124978b8030f0338f0724523545d5e Mon Sep 17 00:00:00 2001 From: typotter Date: Wed, 1 Apr 2026 16:59:27 -0600 Subject: [PATCH 1/6] 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. --- .../feature-flagging-api/build.gradle.kts | 2 + .../trace/api/openfeature/FlagEvalHook.java | 40 +++++ .../api/openfeature/FlagEvalMetrics.java | 80 +++++++++ .../trace/api/openfeature/Provider.java | 13 ++ .../api/openfeature/FlagEvalHookTest.java | 126 ++++++++++++++ .../api/openfeature/FlagEvalMetricsTest.java | 158 ++++++++++++++++++ .../trace/api/openfeature/ProviderTest.java | 24 +++ 7 files changed, 443 insertions(+) create mode 100644 products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalHook.java create mode 100644 products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetrics.java create mode 100644 products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalHookTest.java create mode 100644 products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsTest.java diff --git a/products/feature-flagging/feature-flagging-api/build.gradle.kts b/products/feature-flagging/feature-flagging-api/build.gradle.kts index df475db801a..def6a16da8c 100644 --- a/products/feature-flagging/feature-flagging-api/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-api/build.gradle.kts @@ -44,8 +44,10 @@ dependencies { api("dev.openfeature:sdk:1.20.1") compileOnly(project(":products:feature-flagging:feature-flagging-bootstrap")) + compileOnly("io.opentelemetry:opentelemetry-api:1.47.0") testImplementation(project(":products:feature-flagging:feature-flagging-bootstrap")) + testImplementation("io.opentelemetry:opentelemetry-api:1.47.0") testImplementation(libs.bundles.junit5) testImplementation(libs.bundles.mockito) testImplementation(libs.moshi) diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalHook.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalHook.java new file mode 100644 index 00000000000..93859ebf407 --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalHook.java @@ -0,0 +1,40 @@ +package datadog.trace.api.openfeature; + +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.Hook; +import dev.openfeature.sdk.HookContext; +import dev.openfeature.sdk.ImmutableMetadata; +import java.util.Map; + +class FlagEvalHook implements Hook { + + private final FlagEvalMetrics metrics; + + FlagEvalHook(FlagEvalMetrics metrics) { + this.metrics = metrics; + } + + @Override + public void finallyAfter( + HookContext ctx, FlagEvaluationDetails details, Map hints) { + if (metrics == null) { + return; + } + try { + String flagKey = details.getFlagKey(); + String variant = details.getVariant(); + String reason = details.getReason(); + dev.openfeature.sdk.ErrorCode errorCode = details.getErrorCode(); + + String allocationKey = null; + ImmutableMetadata metadata = details.getFlagMetadata(); + if (metadata != null) { + allocationKey = metadata.getString("allocationKey"); + } + + metrics.record(flagKey, variant, reason, errorCode, allocationKey); + } catch (Exception e) { + // Never let metrics recording break flag evaluation + } + } +} diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetrics.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetrics.java new file mode 100644 index 00000000000..ce3f174fc47 --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetrics.java @@ -0,0 +1,80 @@ +package datadog.trace.api.openfeature; + +import dev.openfeature.sdk.ErrorCode; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.api.metrics.LongCounter; +import io.opentelemetry.api.metrics.Meter; + +class FlagEvalMetrics { + + private static final String METER_NAME = "ddtrace.openfeature"; + private static final String METRIC_NAME = "feature_flag.evaluations"; + private static final String METRIC_UNIT = "{evaluation}"; + private static final String METRIC_DESC = "Number of feature flag evaluations"; + + private static final AttributeKey ATTR_FLAG_KEY = + AttributeKey.stringKey("feature_flag.key"); + private static final AttributeKey ATTR_VARIANT = + AttributeKey.stringKey("feature_flag.result.variant"); + private static final AttributeKey ATTR_REASON = + AttributeKey.stringKey("feature_flag.result.reason"); + private static final AttributeKey ATTR_ERROR_TYPE = AttributeKey.stringKey("error.type"); + private static final AttributeKey ATTR_ALLOCATION_KEY = + AttributeKey.stringKey("feature_flag.result.allocation_key"); + + private volatile LongCounter counter; + + FlagEvalMetrics() { + try { + Meter meter = GlobalOpenTelemetry.getMeterProvider().meterBuilder(METER_NAME).build(); + counter = + meter + .counterBuilder(METRIC_NAME) + .setUnit(METRIC_UNIT) + .setDescription(METRIC_DESC) + .build(); + } catch (NoClassDefFoundError | Exception e) { + // OTel API not on classpath or initialization failed — counter stays null (no-op) + counter = null; + } + } + + /** Package-private constructor for testing with a mock counter. */ + FlagEvalMetrics(LongCounter counter) { + this.counter = counter; + } + + void record( + String flagKey, String variant, String reason, ErrorCode errorCode, String allocationKey) { + LongCounter c = counter; + if (c == null) { + return; + } + try { + AttributesBuilder builder = + Attributes.builder() + .put(ATTR_FLAG_KEY, flagKey) + .put(ATTR_VARIANT, variant != null ? variant : "") + .put(ATTR_REASON, reason != null ? reason.toLowerCase() : "unknown"); + + if (errorCode != null) { + builder.put(ATTR_ERROR_TYPE, errorCode.name().toLowerCase()); + } + + if (allocationKey != null && !allocationKey.isEmpty()) { + builder.put(ATTR_ALLOCATION_KEY, allocationKey); + } + + c.add(1, builder.build()); + } catch (Exception e) { + // Never let metrics recording break flag evaluation + } + } + + void shutdown() { + counter = null; + } +} diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java index 0b0faf38c1c..bc62aaccfa7 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java @@ -5,6 +5,7 @@ import de.thetaphi.forbiddenapis.SuppressForbidden; import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.EventProvider; +import dev.openfeature.sdk.Hook; import dev.openfeature.sdk.Metadata; import dev.openfeature.sdk.ProviderEvaluation; import dev.openfeature.sdk.ProviderEvent; @@ -14,6 +15,8 @@ import dev.openfeature.sdk.exceptions.OpenFeatureError; import dev.openfeature.sdk.exceptions.ProviderNotReadyError; import java.lang.reflect.Constructor; +import java.util.Collections; +import java.util.List; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -25,6 +28,8 @@ public class Provider extends EventProvider implements Metadata { private volatile Evaluator evaluator; private final Options options; private final AtomicBoolean initialized = new AtomicBoolean(false); + private final FlagEvalMetrics flagEvalMetrics; + private final FlagEvalHook flagEvalHook; public Provider() { this(DEFAULT_OPTIONS, null); @@ -37,6 +42,8 @@ public Provider(final Options options) { Provider(final Options options, final Evaluator evaluator) { this.options = options; this.evaluator = evaluator; + this.flagEvalMetrics = new FlagEvalMetrics(); + this.flagEvalHook = new FlagEvalHook(flagEvalMetrics); } @Override @@ -77,8 +84,14 @@ private Evaluator buildEvaluator() throws Exception { return (Evaluator) ctor.newInstance((Runnable) this::onConfigurationChange); } + @Override + public List getProviderHooks() { + return Collections.singletonList(flagEvalHook); + } + @Override public void shutdown() { + flagEvalMetrics.shutdown(); if (evaluator != null) { evaluator.shutdown(); } diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalHookTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalHookTest.java new file mode 100644 index 00000000000..8ed17d91cbb --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalHookTest.java @@ -0,0 +1,126 @@ +package datadog.trace.api.openfeature; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import dev.openfeature.sdk.ErrorCode; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.Reason; +import java.util.Collections; +import org.junit.jupiter.api.Test; + +class FlagEvalHookTest { + + @Test + void finallyAfterRecordsBasicEvaluation() { + FlagEvalMetrics metrics = mock(FlagEvalMetrics.class); + FlagEvalHook hook = new FlagEvalHook(metrics); + + FlagEvaluationDetails details = + FlagEvaluationDetails.builder() + .flagKey("my-flag") + .value("on-value") + .variant("on") + .reason(Reason.TARGETING_MATCH.name()) + .flagMetadata( + ImmutableMetadata.builder().addString("allocationKey", "default-alloc").build()) + .build(); + + hook.finallyAfter(null, details, Collections.emptyMap()); + + verify(metrics) + .record( + eq("my-flag"), + eq("on"), + eq(Reason.TARGETING_MATCH.name()), + isNull(), + eq("default-alloc")); + } + + @Test + void finallyAfterRecordsErrorEvaluation() { + FlagEvalMetrics metrics = mock(FlagEvalMetrics.class); + FlagEvalHook hook = new FlagEvalHook(metrics); + + FlagEvaluationDetails details = + FlagEvaluationDetails.builder() + .flagKey("missing-flag") + .value("default") + .reason(Reason.ERROR.name()) + .errorCode(ErrorCode.FLAG_NOT_FOUND) + .build(); + + hook.finallyAfter(null, details, Collections.emptyMap()); + + verify(metrics) + .record( + eq("missing-flag"), + isNull(), + eq(Reason.ERROR.name()), + eq(ErrorCode.FLAG_NOT_FOUND), + isNull()); + } + + @Test + void finallyAfterHandlesNullFlagMetadata() { + FlagEvalMetrics metrics = mock(FlagEvalMetrics.class); + FlagEvalHook hook = new FlagEvalHook(metrics); + + FlagEvaluationDetails details = + FlagEvaluationDetails.builder() + .flagKey("my-flag") + .value(true) + .variant("on") + .reason(Reason.TARGETING_MATCH.name()) + .build(); + + hook.finallyAfter(null, details, Collections.emptyMap()); + + verify(metrics) + .record(eq("my-flag"), eq("on"), eq(Reason.TARGETING_MATCH.name()), isNull(), isNull()); + } + + @Test + void finallyAfterHandlesNullVariantAndReason() { + FlagEvalMetrics metrics = mock(FlagEvalMetrics.class); + FlagEvalHook hook = new FlagEvalHook(metrics); + + FlagEvaluationDetails details = + FlagEvaluationDetails.builder().flagKey("my-flag").value("default").build(); + + hook.finallyAfter(null, details, Collections.emptyMap()); + + verify(metrics).record(eq("my-flag"), isNull(), isNull(), isNull(), isNull()); + } + + @Test + void finallyAfterNeverThrows() { + FlagEvalMetrics metrics = mock(FlagEvalMetrics.class); + FlagEvalHook hook = new FlagEvalHook(metrics); + + // Should not throw even with completely null inputs + hook.finallyAfter(null, null, null); + + verifyNoInteractions(metrics); + } + + @Test + void finallyAfterIsNoOpWhenMetricsIsNull() { + FlagEvalHook hook = new FlagEvalHook(null); + + FlagEvaluationDetails details = + FlagEvaluationDetails.builder() + .flagKey("my-flag") + .value(true) + .variant("on") + .reason(Reason.TARGETING_MATCH.name()) + .build(); + + // Should not throw + hook.finallyAfter(null, details, Collections.emptyMap()); + } +} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsTest.java new file mode 100644 index 00000000000..13261f366fe --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/FlagEvalMetricsTest.java @@ -0,0 +1,158 @@ +package datadog.trace.api.openfeature; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import dev.openfeature.sdk.ErrorCode; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.metrics.LongCounter; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class FlagEvalMetricsTest { + + @Test + void recordBasicAttributes() { + LongCounter counter = mock(LongCounter.class); + FlagEvalMetrics metrics = new FlagEvalMetrics(counter); + + metrics.record("my-flag", "on", "TARGETING_MATCH", null, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); + verify(counter).add(eq(1L), captor.capture()); + + Attributes attrs = captor.getValue(); + assertAttribute(attrs, "feature_flag.key", "my-flag"); + assertAttribute(attrs, "feature_flag.result.variant", "on"); + assertAttribute(attrs, "feature_flag.result.reason", "targeting_match"); + assertNoAttribute(attrs, "error.type"); + assertNoAttribute(attrs, "feature_flag.result.allocation_key"); + } + + @Test + void recordErrorAttributes() { + LongCounter counter = mock(LongCounter.class); + FlagEvalMetrics metrics = new FlagEvalMetrics(counter); + + metrics.record("missing-flag", "", "ERROR", ErrorCode.FLAG_NOT_FOUND, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); + verify(counter).add(eq(1L), captor.capture()); + + Attributes attrs = captor.getValue(); + assertAttribute(attrs, "feature_flag.key", "missing-flag"); + assertAttribute(attrs, "feature_flag.result.variant", ""); + assertAttribute(attrs, "feature_flag.result.reason", "error"); + assertAttribute(attrs, "error.type", "flag_not_found"); + assertNoAttribute(attrs, "feature_flag.result.allocation_key"); + } + + @Test + void recordTypeMismatchError() { + LongCounter counter = mock(LongCounter.class); + FlagEvalMetrics metrics = new FlagEvalMetrics(counter); + + metrics.record("my-flag", "", "ERROR", ErrorCode.TYPE_MISMATCH, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); + verify(counter).add(eq(1L), captor.capture()); + + Attributes attrs = captor.getValue(); + assertAttribute(attrs, "error.type", "type_mismatch"); + } + + @Test + void recordWithAllocationKey() { + LongCounter counter = mock(LongCounter.class); + FlagEvalMetrics metrics = new FlagEvalMetrics(counter); + + metrics.record("my-flag", "on", "TARGETING_MATCH", null, "default-allocation"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); + verify(counter).add(eq(1L), captor.capture()); + + Attributes attrs = captor.getValue(); + assertAttribute(attrs, "feature_flag.result.allocation_key", "default-allocation"); + } + + @Test + void recordOmitsEmptyAllocationKey() { + LongCounter counter = mock(LongCounter.class); + FlagEvalMetrics metrics = new FlagEvalMetrics(counter); + + metrics.record("my-flag", "on", "TARGETING_MATCH", null, ""); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); + verify(counter).add(eq(1L), captor.capture()); + + Attributes attrs = captor.getValue(); + assertNoAttribute(attrs, "feature_flag.result.allocation_key"); + } + + @Test + void recordNullVariantBecomesEmptyString() { + LongCounter counter = mock(LongCounter.class); + FlagEvalMetrics metrics = new FlagEvalMetrics(counter); + + metrics.record("my-flag", null, "DEFAULT", null, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); + verify(counter).add(eq(1L), captor.capture()); + + Attributes attrs = captor.getValue(); + assertAttribute(attrs, "feature_flag.result.variant", ""); + } + + @Test + void recordNullReasonBecomesUnknown() { + LongCounter counter = mock(LongCounter.class); + FlagEvalMetrics metrics = new FlagEvalMetrics(counter); + + metrics.record("my-flag", "on", null, null, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Attributes.class); + verify(counter).add(eq(1L), captor.capture()); + + Attributes attrs = captor.getValue(); + assertAttribute(attrs, "feature_flag.result.reason", "unknown"); + } + + @Test + void recordIsNoOpWhenCounterIsNull() { + FlagEvalMetrics metrics = new FlagEvalMetrics(null); + // Should not throw + metrics.record("my-flag", "on", "TARGETING_MATCH", null, null); + } + + @Test + void shutdownClearsCounter() { + LongCounter counter = mock(LongCounter.class); + FlagEvalMetrics metrics = new FlagEvalMetrics(counter); + + metrics.shutdown(); + metrics.record("my-flag", "on", "TARGETING_MATCH", null, null); + + verifyNoInteractions(counter); + } + + private static void assertAttribute(Attributes attrs, String key, String expected) { + String value = + attrs.asMap().entrySet().stream() + .filter(e -> e.getKey().getKey().equals(key)) + .map(e -> e.getValue().toString()) + .findFirst() + .orElse(null); + if (!expected.equals(value)) { + throw new AssertionError("Expected attribute " + key + "=" + expected + " but got " + value); + } + } + + private static void assertNoAttribute(Attributes attrs, String key) { + boolean present = attrs.asMap().keySet().stream().anyMatch(k -> k.getKey().equals(key)); + if (present) { + throw new AssertionError("Expected no attribute " + key + " but it was present"); + } + } +} diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderTest.java index 4ed1495bd00..87a80f59e20 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderTest.java +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ProviderTest.java @@ -24,6 +24,7 @@ import dev.openfeature.sdk.EventDetails; import dev.openfeature.sdk.Features; import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.Hook; import dev.openfeature.sdk.OpenFeatureAPI; import dev.openfeature.sdk.ProviderEvaluation; import dev.openfeature.sdk.ProviderEvent; @@ -31,6 +32,7 @@ import dev.openfeature.sdk.Value; import dev.openfeature.sdk.exceptions.FatalError; import dev.openfeature.sdk.exceptions.ProviderNotReadyError; +import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.function.Consumer; @@ -138,6 +140,28 @@ protected Class loadEvaluatorClass() throws ClassNotFoundException { })); } + @Test + public void testGetProviderHooksReturnsFlagEvalHook() { + Provider provider = + new Provider(new Options().initTimeout(10, MILLISECONDS), mock(Evaluator.class)); + List hooks = provider.getProviderHooks(); + assertThat(hooks.size(), equalTo(1)); + assertThat(hooks.get(0) instanceof FlagEvalHook, equalTo(true)); + } + + @Test + public void testShutdownCleansUpMetrics() throws Exception { + Evaluator evaluator = mock(Evaluator.class); + when(evaluator.initialize(anyLong(), any(), any())).thenReturn(true); + Provider provider = new Provider(new Options().initTimeout(10, MILLISECONDS), evaluator); + provider.initialize(null); + provider.shutdown(); + verify(evaluator).shutdown(); + // After shutdown, getProviderHooks still returns a list (hook is still present but metrics is + // shut down) + assertThat(provider.getProviderHooks().size(), equalTo(1)); + } + public interface EvaluateMethod { FlagEvaluationDetails evaluate(Features client, String flag, E defaultValue); } From c5467fb6e5b2afd373bbfd166792c309f10655c4 Mon Sep 17 00:00:00 2001 From: typotter Date: Wed, 1 Apr 2026 20:54:24 -0600 Subject: [PATCH 2/6] Use own SdkMeterProvider with OTLP HTTP exporter for eval metrics Replace GlobalOpenTelemetry.getMeterProvider() with a dedicated SdkMeterProvider + OtlpHttpMetricExporter that sends metrics directly to the DD Agent's OTLP endpoint (default :4318/v1/metrics). This avoids the agent's OTel class shading issue where the agent relocates io.opentelemetry.api.* to datadog.trace.bootstrap.otel.api.*, making GlobalOpenTelemetry calls from the dd-openfeature jar hit the unshaded no-op provider instead of the agent's shim. Requires opentelemetry-sdk-metrics and opentelemetry-exporter-otlp on the application classpath. Falls back to no-op if absent. System tests: 11/17 pass. 6 failures are pre-existing DDEvaluator gaps (reason mapping, parse errors, type mismatch strictness). --- .../feature-flagging-api/build.gradle.kts | 4 ++ .../api/openfeature/FlagEvalMetrics.java | 46 +++++++++++++++++-- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/products/feature-flagging/feature-flagging-api/build.gradle.kts b/products/feature-flagging/feature-flagging-api/build.gradle.kts index def6a16da8c..e630ec1e6b6 100644 --- a/products/feature-flagging/feature-flagging-api/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-api/build.gradle.kts @@ -45,9 +45,13 @@ dependencies { compileOnly(project(":products:feature-flagging:feature-flagging-bootstrap")) compileOnly("io.opentelemetry:opentelemetry-api:1.47.0") + compileOnly("io.opentelemetry:opentelemetry-sdk-metrics:1.47.0") + compileOnly("io.opentelemetry:opentelemetry-exporter-otlp:1.47.0") testImplementation(project(":products:feature-flagging:feature-flagging-bootstrap")) testImplementation("io.opentelemetry:opentelemetry-api:1.47.0") + testImplementation("io.opentelemetry:opentelemetry-sdk-metrics:1.47.0") + testImplementation("io.opentelemetry:opentelemetry-exporter-otlp:1.47.0") testImplementation(libs.bundles.junit5) testImplementation(libs.bundles.mockito) testImplementation(libs.moshi) diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetrics.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetrics.java index ce3f174fc47..c1eeab95e52 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetrics.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetrics.java @@ -1,19 +1,27 @@ package datadog.trace.api.openfeature; import dev.openfeature.sdk.ErrorCode; -import io.opentelemetry.api.GlobalOpenTelemetry; import io.opentelemetry.api.common.AttributeKey; import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.common.AttributesBuilder; import io.opentelemetry.api.metrics.LongCounter; import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.exporter.otlp.http.metrics.OtlpHttpMetricExporter; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.metrics.export.PeriodicMetricReader; +import java.io.Closeable; +import java.time.Duration; -class FlagEvalMetrics { +class FlagEvalMetrics implements Closeable { private static final String METER_NAME = "ddtrace.openfeature"; private static final String METRIC_NAME = "feature_flag.evaluations"; private static final String METRIC_UNIT = "{evaluation}"; private static final String METRIC_DESC = "Number of feature flag evaluations"; + private static final Duration EXPORT_INTERVAL = Duration.ofSeconds(10); + + private static final String DEFAULT_ENDPOINT = "http://localhost:4318/v1/metrics"; + private static final String ENDPOINT_ENV = "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT"; private static final AttributeKey ATTR_FLAG_KEY = AttributeKey.stringKey("feature_flag.key"); @@ -26,10 +34,24 @@ class FlagEvalMetrics { AttributeKey.stringKey("feature_flag.result.allocation_key"); private volatile LongCounter counter; + private volatile SdkMeterProvider meterProvider; FlagEvalMetrics() { try { - Meter meter = GlobalOpenTelemetry.getMeterProvider().meterBuilder(METER_NAME).build(); + String endpoint = System.getenv(ENDPOINT_ENV); + if (endpoint == null || endpoint.isEmpty()) { + endpoint = DEFAULT_ENDPOINT; + } + + OtlpHttpMetricExporter exporter = + OtlpHttpMetricExporter.builder().setEndpoint(endpoint).build(); + + PeriodicMetricReader reader = + PeriodicMetricReader.builder(exporter).setInterval(EXPORT_INTERVAL).build(); + + meterProvider = SdkMeterProvider.builder().registerMetricReader(reader).build(); + + Meter meter = meterProvider.meterBuilder(METER_NAME).build(); counter = meter .counterBuilder(METRIC_NAME) @@ -37,14 +59,16 @@ class FlagEvalMetrics { .setDescription(METRIC_DESC) .build(); } catch (NoClassDefFoundError | Exception e) { - // OTel API not on classpath or initialization failed — counter stays null (no-op) + // OTel SDK not on classpath or initialization failed — counter stays null (no-op) counter = null; + meterProvider = null; } } /** Package-private constructor for testing with a mock counter. */ FlagEvalMetrics(LongCounter counter) { this.counter = counter; + this.meterProvider = null; } void record( @@ -74,7 +98,21 @@ void record( } } + @Override + public void close() { + shutdown(); + } + void shutdown() { counter = null; + SdkMeterProvider mp = meterProvider; + if (mp != null) { + meterProvider = null; + try { + mp.close(); + } catch (Exception e) { + // Ignore shutdown errors + } + } } } From 1816d301e1fd546693e1988001b2a1947ea92bbe Mon Sep 17 00:00:00 2001 From: typotter Date: Thu, 2 Apr 2026 00:25:20 -0600 Subject: [PATCH 3/6] Address code review feedback for eval metrics - Add explicit null guard for details in FlagEvalHook.finallyAfter() - Add OTEL_EXPORTER_OTLP_ENDPOINT generic env var fallback with /v1/metrics path appended (per OTel spec fallback chain) - Add comments clarifying signal-specific vs generic endpoint behavior --- .../datadog/trace/api/openfeature/FlagEvalHook.java | 2 +- .../datadog/trace/api/openfeature/FlagEvalMetrics.java | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalHook.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalHook.java index 93859ebf407..8562db2b6cf 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalHook.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalHook.java @@ -17,7 +17,7 @@ class FlagEvalHook implements Hook { @Override public void finallyAfter( HookContext ctx, FlagEvaluationDetails details, Map hints) { - if (metrics == null) { + if (metrics == null || details == null) { return; } try { diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetrics.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetrics.java index c1eeab95e52..15d9f50a07b 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetrics.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetrics.java @@ -21,7 +21,10 @@ class FlagEvalMetrics implements Closeable { private static final Duration EXPORT_INTERVAL = Duration.ofSeconds(10); private static final String DEFAULT_ENDPOINT = "http://localhost:4318/v1/metrics"; + // Signal-specific env var (used as-is, must include /v1/metrics path) private static final String ENDPOINT_ENV = "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT"; + // Generic env var fallback (base URL, /v1/metrics is appended) + private static final String ENDPOINT_GENERIC_ENV = "OTEL_EXPORTER_OTLP_ENDPOINT"; private static final AttributeKey ATTR_FLAG_KEY = AttributeKey.stringKey("feature_flag.key"); @@ -40,7 +43,12 @@ class FlagEvalMetrics implements Closeable { try { String endpoint = System.getenv(ENDPOINT_ENV); if (endpoint == null || endpoint.isEmpty()) { - endpoint = DEFAULT_ENDPOINT; + String base = System.getenv(ENDPOINT_GENERIC_ENV); + if (base != null && !base.isEmpty()) { + endpoint = base.endsWith("/") ? base + "v1/metrics" : base + "/v1/metrics"; + } else { + endpoint = DEFAULT_ENDPOINT; + } } OtlpHttpMetricExporter exporter = From 3d789f00a442bf708260d4b42a63a63cd2c8221d Mon Sep 17 00:00:00 2001 From: typotter Date: Wed, 8 Apr 2026 13:25:05 -0600 Subject: [PATCH 4/6] Fix NoClassDefFoundError when OTel SDK absent from classpath When the OTel SDK jars are not on the application classpath, loading FlagEvalMetrics fails because field types reference OTel SDK classes (SdkMeterProvider). This propagated as an uncaught NoClassDefFoundError from the Provider constructor, crashing provider initialization. Fix: - Change meterProvider field type from SdkMeterProvider to Closeable (always on classpath), use local SdkMeterProvider variable inside try block - Catch NoClassDefFoundError in Provider constructor when creating FlagEvalMetrics - Null-safe getProviderHooks() and shutdown() when metrics is null --- .../trace/api/openfeature/FlagEvalMetrics.java | 12 ++++++++---- .../datadog/trace/api/openfeature/Provider.java | 17 ++++++++++++++--- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetrics.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetrics.java index 15d9f50a07b..1810ba353df 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetrics.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalMetrics.java @@ -37,7 +37,9 @@ class FlagEvalMetrics implements Closeable { AttributeKey.stringKey("feature_flag.result.allocation_key"); private volatile LongCounter counter; - private volatile SdkMeterProvider meterProvider; + // Typed as Closeable to avoid loading SdkMeterProvider at class-load time + // when the OTel SDK is absent from the classpath + private volatile java.io.Closeable meterProvider; FlagEvalMetrics() { try { @@ -57,9 +59,11 @@ class FlagEvalMetrics implements Closeable { PeriodicMetricReader reader = PeriodicMetricReader.builder(exporter).setInterval(EXPORT_INTERVAL).build(); - meterProvider = SdkMeterProvider.builder().registerMetricReader(reader).build(); + SdkMeterProvider sdkMeterProvider = + SdkMeterProvider.builder().registerMetricReader(reader).build(); + meterProvider = sdkMeterProvider; - Meter meter = meterProvider.meterBuilder(METER_NAME).build(); + Meter meter = sdkMeterProvider.meterBuilder(METER_NAME).build(); counter = meter .counterBuilder(METRIC_NAME) @@ -113,7 +117,7 @@ public void close() { void shutdown() { counter = null; - SdkMeterProvider mp = meterProvider; + java.io.Closeable mp = meterProvider; if (mp != null) { meterProvider = null; try { diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java index bc62aaccfa7..82b4f757ee6 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java @@ -42,8 +42,14 @@ public Provider(final Options options) { Provider(final Options options, final Evaluator evaluator) { this.options = options; this.evaluator = evaluator; - this.flagEvalMetrics = new FlagEvalMetrics(); - this.flagEvalHook = new FlagEvalHook(flagEvalMetrics); + FlagEvalMetrics metrics = null; + try { + metrics = new FlagEvalMetrics(); + } catch (NoClassDefFoundError | Exception e) { + // OTel classes not on classpath — metrics disabled + } + this.flagEvalMetrics = metrics; + this.flagEvalHook = new FlagEvalHook(metrics); } @Override @@ -86,12 +92,17 @@ private Evaluator buildEvaluator() throws Exception { @Override public List getProviderHooks() { + if (flagEvalHook == null) { + return Collections.emptyList(); + } return Collections.singletonList(flagEvalHook); } @Override public void shutdown() { - flagEvalMetrics.shutdown(); + if (flagEvalMetrics != null) { + flagEvalMetrics.shutdown(); + } if (evaluator != null) { evaluator.shutdown(); } From 69c552998a2107872f63cb53b15b412ceeabab95 Mon Sep 17 00:00:00 2001 From: typotter Date: Wed, 8 Apr 2026 16:09:46 -0600 Subject: [PATCH 5/6] Move FlagEvalHook construction inside try/catch block FlagEvalHook references FlagEvalMetrics in its field declaration. On JVMs that eagerly verify field types during class loading, constructing FlagEvalHook outside the try/catch could throw NoClassDefFoundError if OTel classes failed to load. Moving it inside the try block ensures both metrics and hook are null-safe when OTel is absent. --- .../src/main/java/datadog/trace/api/openfeature/Provider.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java index 82b4f757ee6..d369a9ea3b3 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/Provider.java @@ -43,13 +43,15 @@ public Provider(final Options options) { this.options = options; this.evaluator = evaluator; FlagEvalMetrics metrics = null; + FlagEvalHook hook = null; try { metrics = new FlagEvalMetrics(); + hook = new FlagEvalHook(metrics); } catch (NoClassDefFoundError | Exception e) { // OTel classes not on classpath — metrics disabled } this.flagEvalMetrics = metrics; - this.flagEvalHook = new FlagEvalHook(metrics); + this.flagEvalHook = hook; } @Override From 18c0441ef0e18e5dd875aa44037901b923dc10a4 Mon Sep 17 00:00:00 2001 From: typotter Date: Thu, 9 Apr 2026 09:34:33 -0600 Subject: [PATCH 6/6] Add README for dd-openfeature with eval metrics setup Documents the published artifact setup, evaluation metrics dependencies (opentelemetry-sdk-metrics, opentelemetry-exporter-otlp), OTLP endpoint configuration, metric attributes, and requirements. --- .../feature-flagging-api/README.md | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 products/feature-flagging/feature-flagging-api/README.md diff --git a/products/feature-flagging/feature-flagging-api/README.md b/products/feature-flagging/feature-flagging-api/README.md new file mode 100644 index 00000000000..31e68572a91 --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/README.md @@ -0,0 +1,82 @@ +# dd-openfeature + +Datadog OpenFeature Provider for Java. Implements the [OpenFeature](https://openfeature.dev/) `FeatureProvider` interface for Datadog's Feature Flags and Experimentation (FFE) product. + +Published as `com.datadoghq:dd-openfeature` on Maven Central. + +## Setup + +```xml + + com.datadoghq + dd-openfeature + ${dd-openfeature.version} + + + dev.openfeature + sdk + 1.20.1 + +``` + +### Evaluation metrics (optional) + +To enable evaluation metrics (`feature_flag.evaluations` counter), add the OpenTelemetry SDK dependencies: + +```xml + + io.opentelemetry + opentelemetry-sdk-metrics + 1.47.0 + + + io.opentelemetry + opentelemetry-exporter-otlp + 1.47.0 + +``` + +Any OpenTelemetry API 1.x version is compatible. If these dependencies are absent, the provider operates normally without metrics. + +## Usage + +```java +import datadog.trace.api.openfeature.Provider; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.Client; + +OpenFeatureAPI api = OpenFeatureAPI.getInstance(); +api.setProviderAndWait(new Provider()); +Client client = api.getClient(); + +boolean enabled = client.getBooleanValue("my-feature", false, + new MutableContext("user-123")); +``` + +## Evaluation metrics + +When the OTel SDK dependencies are on the classpath, the provider records a `feature_flag.evaluations` counter via OTLP HTTP/protobuf. Metrics are exported every 10 seconds to the Datadog Agent's OTLP receiver. + +### Configuration + +| Environment variable | Description | Default | +|---|---|---| +| `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | Signal-specific OTLP endpoint (used as-is) | — | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | Generic OTLP endpoint (`/v1/metrics` appended) | — | +| (none set) | Default endpoint | `http://localhost:4318/v1/metrics` | + +### Metric attributes + +| Attribute | Description | +|---|---| +| `feature_flag.key` | Flag key | +| `feature_flag.result.variant` | Resolved variant key | +| `feature_flag.result.reason` | Evaluation reason (lowercased) | +| `error.type` | Error code (lowercased, only on error) | +| `feature_flag.result.allocation_key` | Allocation key (when present) | + +## Requirements + +- Java 11+ +- Datadog Agent with Remote Configuration enabled +- `DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED=true`