From fe4c1539fc432948faa37580210de3f32c9353c4 Mon Sep 17 00:00:00 2001 From: typotter Date: Wed, 1 Apr 2026 16:59:27 -0600 Subject: [PATCH 1/3] 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/3] 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/3] 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 =