From fe4c1539fc432948faa37580210de3f32c9353c4 Mon Sep 17 00:00:00 2001 From: typotter Date: Wed, 1 Apr 2026 16:59:27 -0600 Subject: [PATCH 1/5] 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 9435c5488fb7377819ffb430418ba0f8a80a7a2a Mon Sep 17 00:00:00 2001 From: typotter Date: Wed, 1 Apr 2026 20:54:24 -0600 Subject: [PATCH 2/5] 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 9be381244d274cc02dfb0048f36bb17c782a061d Mon Sep 17 00:00:00 2001 From: typotter Date: Thu, 2 Apr 2026 00:25:20 -0600 Subject: [PATCH 3/5] 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 ace934eca2e9004bf3ce7c4a2c4f34aa71c29af1 Mon Sep 17 00:00:00 2001 From: typotter Date: Wed, 8 Apr 2026 13:25:05 -0600 Subject: [PATCH 4/5] 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 e927da9d9fa560de857f73266c1a1629e381fcfe Mon Sep 17 00:00:00 2001 From: typotter Date: Thu, 9 Apr 2026 09:40:43 -0600 Subject: [PATCH 5/5] fix(openfeature): return PARSE_ERROR for invalid regex in flag condition --- .../trace/api/openfeature/DDEvaluator.java | 13 +++++----- .../api/openfeature/DDEvaluatorTest.java | 24 ++++++++++++++++++- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java index 44cd1fc9efe..cda22855ef8 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/DDEvaluator.java @@ -38,6 +38,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; class DDEvaluator implements Evaluator, FeatureFlaggingGateway.ConfigListener { @@ -147,6 +148,8 @@ public ProviderEvaluation evaluate( .value(defaultValue) .reason(Reason.DEFAULT.name()) .build(); + } catch (final PatternSyntaxException e) { + return error(defaultValue, ErrorCode.PARSE_ERROR, e); } catch (final NumberFormatException e) { return error(defaultValue, ErrorCode.TYPE_MISMATCH, e); } catch (final Exception e) { @@ -250,12 +253,10 @@ private static boolean evaluateCondition( } private static boolean matchesRegex(final Object attributeValue, final Object conditionValue) { - try { - final Pattern pattern = Pattern.compile(String.valueOf(conditionValue)); - return pattern.matcher(String.valueOf(attributeValue)).find(); - } catch (Exception e) { - return false; - } + // PatternSyntaxException is intentionally not caught here so it propagates to evaluate(), + // which maps it to ErrorCode.PARSE_ERROR. + final Pattern pattern = Pattern.compile(String.valueOf(conditionValue)); + return pattern.matcher(String.valueOf(attributeValue)).find(); } private static boolean isOneOf(final Object attributeValue, final Object conditionValue) { diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java index 3df51d1bde3..a9523a3d24b 100644 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java @@ -462,7 +462,12 @@ private static List> evaluateTestCases() { .result( new Result<>("null-handled") .reason(TARGETING_MATCH.name()) - .variant("null-variant"))); + .variant("null-variant")), + new TestCase<>("default") + .flag("invalid-regex-flag") + .targetingKey("user-123") + .context("email", "user@example.com") + .result(new Result<>("default").reason(ERROR.name()).errorCode(ErrorCode.PARSE_ERROR))); } @MethodSource("evaluateTestCases") @@ -551,6 +556,7 @@ private ServerConfiguration createTestConfiguration() { flags.put("not-one-of-false-flag", createNotOneOfFalseFlag()); flags.put("null-context-values-flag", createNullContextValuesFlag()); flags.put("country-rule-flag", createCountryRuleFlag()); + flags.put("invalid-regex-flag", createInvalidRegexFlag()); return new ServerConfiguration(null, null, null, flags); } @@ -1236,6 +1242,22 @@ private Flag createCountryRuleFlag() { asList(usAllocation, globalAllocation)); } + private Flag createInvalidRegexFlag() { + final Map variants = new HashMap<>(); + variants.put("matched", new Variant("matched", "matched-value")); + + // Condition with an intentionally invalid regex pattern (unclosed bracket) + final List conditions = + singletonList(new ConditionConfiguration(ConditionOperator.MATCHES, "email", "[invalid")); + final List rules = singletonList(new Rule(conditions)); + final List splits = singletonList(new Split(emptyList(), "matched", null)); + final Allocation allocation = + new Allocation("invalid-regex-alloc", rules, null, null, splits, false); + + return new Flag( + "invalid-regex-flag", true, ValueType.STRING, variants, singletonList(allocation)); + } + private static Map mapOf(final Object... props) { final Map result = new HashMap<>(props.length << 1); int index = 0;