From c3ff383322919393bb214b714e737db37bd0f632 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 2 Sep 2026 21:38:50 +0000 Subject: [PATCH 1/6] Add telemetry-free OpenFeature providers Allow OpenFeature domains to evaluate flags without producing Datadog telemetry. Environment: Datadog workspace --- .../feature-flagging-api/README.md | 33 +++++ .../trace/api/openfeature/DDEvaluator.java | 79 ++++-------- .../trace/api/openfeature/ExposureHook.java | 54 ++++++++ .../api/openfeature/FlagEvalLoggingHook.java | 3 +- .../trace/api/openfeature/Provider.java | 85 +++++++++---- .../api/openfeature/DDEvaluatorTest.java | 19 ++- .../api/openfeature/ExposureHookTest.java | 103 ++++++++++++++++ .../trace/api/openfeature/ProviderTest.java | 116 +++++++++++++++--- 8 files changed, 394 insertions(+), 98 deletions(-) create mode 100644 products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ExposureHook.java create mode 100644 products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExposureHookTest.java diff --git a/products/feature-flagging/feature-flagging-api/README.md b/products/feature-flagging/feature-flagging-api/README.md index 74bbccca2e2..1e0d27e5213 100644 --- a/products/feature-flagging/feature-flagging-api/README.md +++ b/products/feature-flagging/feature-flagging-api/README.md @@ -55,6 +55,39 @@ boolean enabled = client.getBooleanValue("my-feature", false, new MutableContext("user-123")); ``` +### Inspecting evaluations without telemetry + +Use OpenFeature domains when one client should perform normal live evaluations and another should +only inspect the result. Register a separate `Provider` for each domain and disable telemetry on the +provider assigned to the inspection domain: + +```java +OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + +api.setProviderAndWait("live", new Provider()); +api.setProviderAndWait( + "peek", + new Provider(new Provider.Options().telemetryEnabled(false))); + +Client checkoutClient = api.getClient("live"); +Client analyticsClient = api.getClient("peek"); + +EvaluationContext checkoutContext = new MutableContext("session-abc"); +EvaluationContext analyticsContext = new MutableContext("user-123"); + +// Evaluates normally and emits the configured Datadog telemetry. +boolean checkoutEnabled = checkoutClient.getBooleanValue( + "my-feature", false, checkoutContext); + +// Returns the same evaluation result without emitting Datadog telemetry. +boolean analyticsEnabled = analyticsClient.getBooleanValue( + "my-feature", false, analyticsContext); +``` + +`telemetryEnabled(false)` suppresses exposures, EVP flag-evaluation events, OpenTelemetry +evaluation metrics, and APM span enrichment for that provider. It does not disable evaluation or +change the configuration used to resolve flags. + ## Evaluation metrics When `DD_METRICS_OTEL_ENABLED=true` and the OpenTelemetry API is on the classpath, the provider 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 a9f51b4a660..5e2cd919287 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 @@ -3,8 +3,6 @@ import static java.util.Arrays.asList; import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.exposure.ExposureEvent; -import datadog.trace.api.featureflag.exposure.Subject; import datadog.trace.api.featureflag.ufc.v1.Allocation; import datadog.trace.api.featureflag.ufc.v1.ConditionConfiguration; import datadog.trace.api.featureflag.ufc.v1.ConditionOperator; @@ -96,10 +94,12 @@ class DDEvaluator implements Evaluator, FeatureFlaggingGateway.ConfigListener { */ static final int MAX_STRUCTURE_PROPERTIES = 256; - // Evaluation-metadata keys consumed by the span-enrichment capture hook (see - // SpanEnrichmentHook). Emitted only when the span-enrichment gate is on. + // Evaluation-metadata keys consumed by the exposure and span-enrichment hooks. The split serial + // id is emitted only when the span-enrichment gate is on; doLog and the evaluation timestamp are + // always emitted for a resolved variant. static final String METADATA_SPLIT_SERIAL_ID = "__dd_split_serial_id"; static final String METADATA_DO_LOG = "__dd_do_log"; + static final String METADATA_EVAL_TIMESTAMP_MS = "__dd_eval_timestamp_ms"; // Stamped on every DD-produced evaluation (including PROVIDER_NOT_READY, with false). Missing // key = non-DD provider; the hook falls back to false (fail-closed). @@ -218,13 +218,11 @@ public ProviderEvaluation evaluate( if (isEmpty(split.shards)) { return resolveVariant( target, - key, defaultValue, flag, split.variationKey, allocation, split, - context, evalTimestampMs, observeFullEvaluationData); } else { @@ -243,13 +241,11 @@ public ProviderEvaluation evaluate( if (allShardsMatch) { return resolveVariant( target, - key, defaultValue, flag, split.variationKey, allocation, split, - context, evalTimestampMs, observeFullEvaluationData); } @@ -497,13 +493,11 @@ private static String getMD5Hash(final String input) { private static ProviderEvaluation resolveVariant( final Class target, - final String key, final T defaultValue, final Flag flag, final String variationKey, final Allocation allocation, final Split split, - final EvaluationContext context, final long evalTimestampMs, final boolean observeFullEvaluationData) { final Variant variant = flag.variations.get(variationKey); @@ -544,41 +538,33 @@ private static ProviderEvaluation resolveVariant( // Stamp eval-time at the resolution point so first/last_evaluation reflect evaluation time, // not hook-fire time. Passed to the hook via provider metadata "__dd_eval_timestamp_ms". + final boolean doLog = allocation.doLog != null && allocation.doLog; final ImmutableMetadata.ImmutableMetadataBuilder metadataBuilder = ImmutableMetadata.builder() .addString("flagKey", flag.key) .addString("variationType", flag.variationType.name()) .addString("allocationKey", allocation.key) - .addLong("__dd_eval_timestamp_ms", evalTimestampMs) - .addBoolean(METADATA_OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData); - // Surface the UFC split's serial id and the allocation's doLog flag for APM span enrichment — - // only when span enrichment is on, so a provider without enrichment pays nothing extra. - // __dd_split_serial_id is omitted when the split carries no serial id; __dd_do_log is always - // present (when enrichment is on) so the span-enrichment hook can decide whether to record the - // subject. + .addLong(METADATA_EVAL_TIMESTAMP_MS, evalTimestampMs) + .addBoolean(METADATA_OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData) + .addBoolean(METADATA_DO_LOG, doLog); + // The exposure hook always needs doLog. The split serial id remains conditional on span + // enrichment so a provider without enrichment does not attach unused serial-id metadata. if (SPAN_ENRICHMENT_ENABLED) { if (split.serialId != null) { metadataBuilder.addInteger(METADATA_SPLIT_SERIAL_ID, split.serialId); } - metadataBuilder.addBoolean(METADATA_DO_LOG, allocation.doLog != null && allocation.doLog); - } - final ProviderEvaluation result = - ProviderEvaluation.builder() - .value(mappedValue) - .reason( - !isEmpty(allocation.rules) - ? Reason.TARGETING_MATCH.name() - : allocation.startAt != null || allocation.endAt != null - ? Reason.DEFAULT.name() - : !isEmpty(split.shards) ? Reason.SPLIT.name() : Reason.STATIC.name()) - .variant(variant.key) - .flagMetadata(metadataBuilder.build()) - .build(); - final boolean doLog = allocation.doLog != null && allocation.doLog; - if (doLog) { - dispatchExposure(key, result, context); } - return result; + return ProviderEvaluation.builder() + .value(mappedValue) + .reason( + !isEmpty(allocation.rules) + ? Reason.TARGETING_MATCH.name() + : allocation.startAt != null || allocation.endAt != null + ? Reason.DEFAULT.name() + : !isEmpty(split.shards) ? Reason.SPLIT.name() : Reason.STATIC.name()) + .variant(variant.key) + .flagMetadata(metadataBuilder.build()) + .build(); } private static Object resolveAttribute(final String name, final EvaluationContext context) { @@ -648,29 +634,6 @@ private static Double parseDouble(final Object value) { return Double.parseDouble(String.valueOf(value)); } - private static void dispatchExposure( - final String flag, final ProviderEvaluation evaluation, final EvaluationContext context) { - final String allocationKey = allocationKey(evaluation); - final String variantKey = evaluation.getVariant(); - if (allocationKey == null || variantKey == null) { - return; - } - final ExposureEvent event = - new ExposureEvent( - System.currentTimeMillis(), - new datadog.trace.api.featureflag.exposure.Allocation(allocationKey), - new datadog.trace.api.featureflag.exposure.Flag(flag), - new datadog.trace.api.featureflag.exposure.Variant(variantKey), - new Subject(context.getTargetingKey(), flattenContext(context))); - - FeatureFlaggingGateway.dispatch(event); - } - - private static String allocationKey(final ProviderEvaluation resolution) { - final ImmutableMetadata meta = resolution.getFlagMetadata(); - return meta == null ? null : meta.getString("allocationKey"); - } - static AbstractMap flattenContext(final EvaluationContext context) { return flattenValues(snapshotValues(context)); } diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ExposureHook.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ExposureHook.java new file mode 100644 index 00000000000..4c7f30295cd --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ExposureHook.java @@ -0,0 +1,54 @@ +package datadog.trace.api.openfeature; + +import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import datadog.trace.api.featureflag.exposure.ExposureEvent; +import datadog.trace.api.featureflag.exposure.Subject; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.Hook; +import dev.openfeature.sdk.HookContext; +import dev.openfeature.sdk.ImmutableMetadata; +import java.util.Map; + +/** OpenFeature hook that dispatches exposure events for allocations with {@code doLog=true}. */ +class ExposureHook implements Hook { + + static final ExposureHook INSTANCE = new ExposureHook<>(); + + @Override + public void after( + final HookContext context, + final FlagEvaluationDetails details, + final Map hints) { + try { + if (details == null) { + return; + } + final ImmutableMetadata metadata = details.getFlagMetadata(); + if (metadata == null + || !Boolean.TRUE.equals(metadata.getBoolean(DDEvaluator.METADATA_DO_LOG))) { + return; + } + final String allocationKey = metadata.getString("allocationKey"); + final String variantKey = details.getVariant(); + final EvaluationContext evaluationContext = context != null ? context.getCtx() : null; + if (allocationKey == null || variantKey == null || evaluationContext == null) { + return; + } + final Long evaluationTimestamp = metadata.getLong(DDEvaluator.METADATA_EVAL_TIMESTAMP_MS); + final long timestamp = + evaluationTimestamp != null ? evaluationTimestamp : System.currentTimeMillis(); + FeatureFlaggingGateway.dispatch( + new ExposureEvent( + timestamp, + new datadog.trace.api.featureflag.exposure.Allocation(allocationKey), + new datadog.trace.api.featureflag.exposure.Flag(details.getFlagKey()), + new datadog.trace.api.featureflag.exposure.Variant(variantKey), + new Subject( + evaluationContext.getTargetingKey(), + DDEvaluator.flattenContext(evaluationContext)))); + } catch (final LinkageError | Exception ignored) { + // Never let exposure recording break flag evaluation. + } + } +} diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalLoggingHook.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalLoggingHook.java index 322f11ac9e1..d538c560fba 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalLoggingHook.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalLoggingHook.java @@ -102,7 +102,8 @@ public void finallyAfter( // eval-time: from flag metadata "__dd_eval_timestamp_ms" (Long), fallback to hook-fire time. // ImmutableMetadata.getLong available since sdk 1.4+. - final Long evalTimeObj = metadata != null ? metadata.getLong("__dd_eval_timestamp_ms") : null; + final Long evalTimeObj = + metadata != null ? metadata.getLong(DDEvaluator.METADATA_EVAL_TIMESTAMP_MS) : null; final long evalTimeMs = evalTimeObj != null ? evalTimeObj : System.currentTimeMillis(); // variant: the OpenFeature variant key (same source as the OTel FlagEvalMetricsHook), NOT the 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 17009d9933a..95b2a09a35b 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 @@ -29,8 +29,7 @@ public class Provider extends EventProvider implements Metadata { private static final Logger log = LoggerFactory.getLogger(Provider.class); static final String METADATA = "datadog-openfeature-provider"; private static final String EVALUATOR_IMPL = "datadog.trace.api.openfeature.DDEvaluator"; - - private static final Options DEFAULT_OPTIONS = new Options().initTimeout(30, SECONDS); + private static final long DEFAULT_INIT_TIMEOUT = 30; private volatile Evaluator evaluator; private final Options options; private final AtomicReference initializationState = @@ -44,7 +43,7 @@ public class Provider extends EventProvider implements Metadata { private final List providerHooks; public Provider() { - this(DEFAULT_OPTIONS, null); + this(new Options(), null); } public Provider(final Options options) { @@ -67,12 +66,14 @@ public Provider(final Options options) { this.evaluator = evaluator; FlagEvalMetrics metrics = null; FlagEvalMetricsHook hook = null; - try { - metrics = new FlagEvalMetrics(); - hook = new FlagEvalMetricsHook(metrics); - } catch (LinkageError | Exception e) { - // This outer catch fires when the metrics helper itself can't load (OTel API absent). - log.warn("Evaluation metrics unavailable — OTel API classes not on classpath", e); + if (options.isTelemetryEnabled()) { + try { + metrics = new FlagEvalMetrics(); + hook = new FlagEvalMetricsHook(metrics); + } catch (LinkageError | Exception e) { + // This outer catch fires when the metrics helper itself can't load (OTel API absent). + log.warn("Evaluation metrics unavailable — OTel API classes not on classpath", e); + } } this.flagEvalMetrics = metrics; this.flagEvalMetricsHook = hook; @@ -80,26 +81,38 @@ public Provider(final Options options) { // Span enrichment is wired ONLY when the gate is on — off means no capture hook and no idle // per-evaluation overhead. final boolean spanEnrichmentEnabled = - spanEnrichmentEnabledOverride != null - ? spanEnrichmentEnabledOverride - : SpanEnrichmentGate.isEnabled(); + options.isTelemetryEnabled() + && (spanEnrichmentEnabledOverride != null + ? spanEnrichmentEnabledOverride + : SpanEnrichmentGate.isEnabled()); this.spanEnrichmentHook = spanEnrichmentEnabled ? new SpanEnrichmentHook() : null; // Precompute the immutable hook list once so getProviderHooks() (called on every evaluation) // allocates nothing, including when the gate is off. - final List hooks = new ArrayList<>(3); + final List hooks = new ArrayList<>(4); if (flagEvalMetricsHook != null) { hooks.add(flagEvalMetricsHook); } - // EVP flagevaluation hook: always registered; no-op when writer is absent (killswitch off). - // Writer is resolved lazily from FeatureFlaggingGateway.getFlagEvalWriter() on each call. - try { - final Hook flagEvalLoggingHook = buildFlagEvalLoggingHook(); - if (flagEvalLoggingHook != null) { - hooks.add(flagEvalLoggingHook); + if (options.isTelemetryEnabled()) { + // Exposure and EVP flag-evaluation hooks are always registered when provider telemetry is + // enabled. Each is a no-op when its agent-side listener or writer is absent. + try { + final Hook exposureHook = buildExposureHook(); + if (exposureHook != null) { + hooks.add(exposureHook); + } + } catch (LinkageError | Exception e) { + // Keep older bootstrap/API combinations working: exposure recording is best-effort. + } + // Writer is resolved lazily from FeatureFlaggingGateway.getFlagEvalWriter() on each call. + try { + final Hook flagEvalLoggingHook = buildFlagEvalLoggingHook(); + if (flagEvalLoggingHook != null) { + hooks.add(flagEvalLoggingHook); + } + } catch (LinkageError | Exception e) { + // Keep older bootstrap/API combinations working: EVP recording is best-effort. } - } catch (LinkageError | Exception e) { - // Keep older bootstrap/API combinations working: EVP recording is best-effort. } if (spanEnrichmentHook != null) { hooks.add(spanEnrichmentHook); @@ -230,6 +243,10 @@ Hook buildFlagEvalLoggingHook() { return FlagEvalLoggingHook.INSTANCE; } + Hook buildExposureHook() { + return ExposureHook.INSTANCE; + } + @Override public void shutdown() { if (flagEvalMetrics != null) { @@ -303,8 +320,9 @@ private enum InitializationState { public static class Options { - private long timeout; - private TimeUnit unit; + private long timeout = DEFAULT_INIT_TIMEOUT; + private TimeUnit unit = SECONDS; + private boolean telemetryEnabled = true; public Options initTimeout(final long timeout, final TimeUnit unit) { this.timeout = timeout; @@ -319,5 +337,26 @@ public long getTimeout() { public TimeUnit getUnit() { return unit; } + + /** + * Enables or disables Datadog telemetry produced by this provider. + * + *

When disabled, evaluations still use the current Datadog configuration and return normal + * results, but the provider emits no exposures, EVP flag-evaluation events, OpenTelemetry + * evaluation metrics, or APM span enrichment. This is useful with OpenFeature domains: bind a + * telemetry-enabled provider to a live domain and a telemetry-disabled provider to a domain + * used only to inspect evaluations. + * + * @param telemetryEnabled whether this provider should produce Datadog telemetry + * @return these options + */ + public Options telemetryEnabled(final boolean telemetryEnabled) { + this.telemetryEnabled = telemetryEnabled; + return this; + } + + public boolean isTelemetryEnabled() { + return telemetryEnabled; + } } } 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 aa4fe133c0d..1450217d899 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 @@ -298,6 +298,18 @@ public void observeFullEvaluationDataStampedFalseOnResolvedVariant() { equalTo(false)); } + @Test + public void doLogIsStampedOnResolvedVariantForExposureHook() { + assertThat( + evaluateMatchingFlag(false, true).getFlagMetadata().getBoolean(DDEvaluator.METADATA_DO_LOG), + equalTo(true)); + assertThat( + evaluateMatchingFlag(false, false) + .getFlagMetadata() + .getBoolean(DDEvaluator.METADATA_DO_LOG), + equalTo(false)); + } + // -- DISABLED path: flag.enabled=false -- @Test @@ -399,11 +411,16 @@ public void observeFullEvaluationDataNullConfigFieldTreatedAsFalse() { // and a single "on" variant whose value maps to the requested Integer type. private static ProviderEvaluation evaluateMatchingFlag( final boolean observeFullEvaluationData) { + return evaluateMatchingFlag(observeFullEvaluationData, false); + } + + private static ProviderEvaluation evaluateMatchingFlag( + final boolean observeFullEvaluationData, final boolean doLog) { final Map variations = new HashMap<>(); variations.put("on", new Variant("on", 1)); final Split split = new Split(emptyList(), "on", emptyMap(), null); final Allocation allocation = - new Allocation("alloc-1", null, null, null, singletonList(split), Boolean.FALSE); + new Allocation("alloc-1", null, null, null, singletonList(split), doLog); return evaluateFlag( new Flag("target", true, ValueType.INTEGER, variations, singletonList(allocation)), observeFullEvaluationData); diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExposureHookTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExposureHookTest.java new file mode 100644 index 00000000000..b2414271ebd --- /dev/null +++ b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExposureHookTest.java @@ -0,0 +1,103 @@ +package datadog.trace.api.openfeature; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import datadog.trace.api.featureflag.exposure.ExposureEvent; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.FlagValueType; +import dev.openfeature.sdk.HookContext; +import dev.openfeature.sdk.ImmutableMetadata; +import dev.openfeature.sdk.MutableContext; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ExposureHookTest { + + private static final long EVALUATION_TIMESTAMP = 1_700_000_000_000L; + + private final List captured = new ArrayList<>(); + private final FeatureFlaggingGateway.ExposureListener listener = captured::add; + + @BeforeEach + void register() { + FeatureFlaggingGateway.addExposureListener(listener); + } + + @AfterEach + void deregister() { + FeatureFlaggingGateway.removeExposureListener(listener); + } + + @Test + void afterDispatchesExposureWhenDoLogIsTrue() { + final MutableContext evaluationContext = new MutableContext("user-1"); + evaluationContext.add("region", "us-east-1"); + + ExposureHook.INSTANCE.after( + hookContext(evaluationContext), details(true), Collections.emptyMap()); + + assertEquals(1, captured.size()); + final ExposureEvent exposure = captured.get(0); + assertEquals(EVALUATION_TIMESTAMP, exposure.timestamp); + assertEquals("allocation-1", exposure.allocation.key); + assertEquals("my-flag", exposure.flag.key); + assertEquals("on", exposure.variant.key); + assertEquals("user-1", exposure.subject.id); + assertEquals("us-east-1", exposure.subject.attributes.get("region")); + } + + @Test + void afterDoesNotDispatchExposureWhenDoLogIsFalse() { + ExposureHook.INSTANCE.after( + hookContext(new MutableContext("user-1")), details(false), Collections.emptyMap()); + + assertTrue(captured.isEmpty()); + } + + @Test + void afterDoesNotDispatchExposureWithoutDatadogMetadata() { + final FlagEvaluationDetails details = + FlagEvaluationDetails.builder().flagKey("my-flag").value("value").variant("on").build(); + + ExposureHook.INSTANCE.after( + hookContext(new MutableContext("user-1")), details, Collections.emptyMap()); + + assertTrue(captured.isEmpty()); + } + + @Test + void afterHandlesNullDetails() { + ExposureHook.INSTANCE.after(null, null, null); + + assertTrue(captured.isEmpty()); + } + + private static HookContext hookContext(final MutableContext evaluationContext) { + return HookContext.builder() + .flagKey("my-flag") + .type(FlagValueType.STRING) + .defaultValue("default") + .ctx(evaluationContext) + .build(); + } + + private static FlagEvaluationDetails details(final boolean doLog) { + return FlagEvaluationDetails.builder() + .flagKey("my-flag") + .value("value") + .variant("on") + .flagMetadata( + ImmutableMetadata.builder() + .addString("allocationKey", "allocation-1") + .addBoolean(DDEvaluator.METADATA_DO_LOG, doLog) + .addLong(DDEvaluator.METADATA_EVAL_TIMESTAMP_MS, EVALUATION_TIMESTAMP) + .build()) + .build(); + } +} 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 d7adf645e74..bd40656429f 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 @@ -6,9 +6,12 @@ import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; @@ -17,6 +20,8 @@ import static org.mockito.Mockito.when; import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import datadog.trace.api.featureflag.SpanEnrichmentEvent; +import datadog.trace.api.featureflag.exposure.ExposureEvent; import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; import datadog.trace.api.featureflag.flagevaluation.FlagEvaluationWriter; import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; @@ -38,7 +43,6 @@ import dev.openfeature.sdk.exceptions.FatalError; import dev.openfeature.sdk.exceptions.ProviderNotReadyError; import java.lang.reflect.Field; -import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -320,14 +324,13 @@ protected Class loadEvaluatorClass() throws ClassNotFoundException { } @Test - public void testGetProviderHooksReturnsFlagEvalMetricsHook() { - Provider provider = + public void testGetProviderHooksReturnsTelemetryHooks() { + final Provider provider = new Provider(new Options().initTimeout(10, MILLISECONDS), mock(Evaluator.class)); - List hooks = provider.getProviderHooks(); - // Two hooks: OTel FlagEvalMetricsHook (index 0) + FlagEvalLoggingHook (index 1) - assertThat(hooks.size(), equalTo(2)); - assertThat(hooks.get(0) instanceof FlagEvalMetricsHook, equalTo(true)); - assertThat(hooks.get(1) instanceof FlagEvalLoggingHook, equalTo(true)); + + assertHasHook(provider, FlagEvalMetricsHook.class); + assertHasHook(provider, ExposureHook.class); + assertHasHook(provider, FlagEvalLoggingHook.class); } @Test @@ -340,10 +343,87 @@ Hook buildFlagEvalLoggingHook() { } }; - List hooks = provider.getProviderHooks(); + assertHasHook(provider, FlagEvalMetricsHook.class); + assertHasHook(provider, ExposureHook.class); + assertFalse( + provider.getProviderHooks().stream().anyMatch(FlagEvalLoggingHook.class::isInstance)); + } + + @Test + public void testTelemetryDisabledProviderHasNoHooks() { + final Provider provider = + new Provider(new Options().telemetryEnabled(false), mock(Evaluator.class), Boolean.TRUE); + + assertTrue(provider.getProviderHooks().isEmpty()); + assertNull(provider.spanEnrichmentHook()); + } + + @Test + public void testOptionsRetainDefaultTimeoutWhenOnlyTelemetryIsConfigured() { + final Options options = new Options().telemetryEnabled(false); + + assertThat(options.getTimeout(), equalTo(30L)); + assertThat(options.getUnit(), equalTo(SECONDS)); + assertFalse(options.isTelemetryEnabled()); + } - assertThat(hooks.size(), equalTo(1)); - assertThat(hooks.get(0) instanceof FlagEvalMetricsHook, equalTo(true)); + @Test + public void testNamedDomainsCanSeparateLiveAndPeekEvaluations() throws Exception { + final AtomicReference flagEvaluation = new AtomicReference<>(); + final AtomicReference exposure = new AtomicReference<>(); + final AtomicReference spanEnrichment = new AtomicReference<>(); + final FeatureFlaggingGateway.ExposureListener exposureListener = exposure::set; + final FeatureFlaggingGateway.SpanEnrichmentListener spanEnrichmentListener = + spanEnrichment::set; + FeatureFlaggingGateway.setFlagEvalWriter(capturingWriter(flagEvaluation)); + FeatureFlaggingGateway.addExposureListener(exposureListener); + FeatureFlaggingGateway.addSpanEnrichmentListener(spanEnrichmentListener); + try { + final Evaluator evaluator = mock(Evaluator.class); + when(evaluator.initialize(anyLong(), any(), any())).thenReturn(true); + when(evaluator.hasConfiguration()).thenReturn(true); + when(evaluator.evaluate(eq(String.class), eq("my-flag"), eq("default"), any())) + .thenReturn( + ProviderEvaluation.builder() + .value("value") + .reason("STATIC") + .variant("on") + .flagMetadata( + ImmutableMetadata.builder() + .addString("allocationKey", "allocation-1") + .addLong(DDEvaluator.METADATA_EVAL_TIMESTAMP_MS, 1_700_000_000_000L) + .addBoolean(DDEvaluator.METADATA_DO_LOG, true) + .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, true) + .addInteger(DDEvaluator.METADATA_SPLIT_SERIAL_ID, 42) + .build()) + .build()); + + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + api.setProviderAndWait("live", new Provider(new Options(), evaluator, Boolean.TRUE)); + api.setProviderAndWait( + "peek", new Provider(new Options().telemetryEnabled(false), evaluator, Boolean.TRUE)); + + final MutableContext context = new MutableContext("user-1"); + context.add("region", "us-east-1"); + final FlagEvaluationDetails peekDetails = + api.getClient("peek").getStringDetails("my-flag", "default", context); + + assertThat(peekDetails.getValue(), equalTo("value")); + assertNull(exposure.get()); + assertNull(flagEvaluation.get()); + assertNull(spanEnrichment.get()); + + final FlagEvaluationDetails liveDetails = + api.getClient("live").getStringDetails("my-flag", "default", context); + + assertThat(liveDetails.getValue(), equalTo("value")); + assertNotNull(exposure.get()); + assertNotNull(flagEvaluation.get()); + assertNotNull(spanEnrichment.get()); + } finally { + FeatureFlaggingGateway.removeExposureListener(exposureListener); + FeatureFlaggingGateway.removeSpanEnrichmentListener(spanEnrichmentListener); + } } @Test @@ -409,14 +489,20 @@ public void testShutdownCleansUpEvaluator() throws Exception { provider.shutdown(); verify(evaluator).shutdown(); - // After shutdown, getProviderHooks still returns a list with both OTel + logging hooks - assertThat(provider.getProviderHooks().size(), equalTo(2)); + assertHasHook(provider, FlagEvalMetricsHook.class); + assertHasHook(provider, ExposureHook.class); + assertHasHook(provider, FlagEvalLoggingHook.class); } private static void assertHasFlagEvalMetricsHook(final Provider provider) { + assertHasHook(provider, FlagEvalMetricsHook.class); + } + + private static void assertHasHook( + final Provider provider, final Class hookClass) { assertTrue( - provider.getProviderHooks().stream().anyMatch(FlagEvalMetricsHook.class::isInstance), - "flag evaluation metrics hook should be registered"); + provider.getProviderHooks().stream().anyMatch(hookClass::isInstance), + hookClass.getSimpleName() + " should be registered"); } public interface EvaluateMethod { From 43c247252e15ae838999947bc7d21496ca7491ad Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 2 Sep 2026 21:52:33 +0000 Subject: [PATCH 2/6] Simplify OpenFeature telemetry suppression Keep exposure dispatch in the evaluator and guard telemetry with a provider-scoped boolean. Environment: Datadog workspace --- .../trace/api/openfeature/DDEvaluator.java | 87 +++++++++++---- .../trace/api/openfeature/ExposureHook.java | 54 --------- .../api/openfeature/FlagEvalLoggingHook.java | 3 +- .../trace/api/openfeature/Provider.java | 31 ++---- .../api/openfeature/DDEvaluatorTest.java | 42 +++++-- .../api/openfeature/ExposureHookTest.java | 103 ------------------ .../trace/api/openfeature/ProviderTest.java | 66 ----------- 7 files changed, 107 insertions(+), 279 deletions(-) delete mode 100644 products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ExposureHook.java delete mode 100644 products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExposureHookTest.java 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 5e2cd919287..3b69215ea77 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 @@ -3,6 +3,8 @@ import static java.util.Arrays.asList; import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import datadog.trace.api.featureflag.exposure.ExposureEvent; +import datadog.trace.api.featureflag.exposure.Subject; import datadog.trace.api.featureflag.ufc.v1.Allocation; import datadog.trace.api.featureflag.ufc.v1.ConditionConfiguration; import datadog.trace.api.featureflag.ufc.v1.ConditionOperator; @@ -94,12 +96,10 @@ class DDEvaluator implements Evaluator, FeatureFlaggingGateway.ConfigListener { */ static final int MAX_STRUCTURE_PROPERTIES = 256; - // Evaluation-metadata keys consumed by the exposure and span-enrichment hooks. The split serial - // id is emitted only when the span-enrichment gate is on; doLog and the evaluation timestamp are - // always emitted for a resolved variant. + // Evaluation-metadata keys consumed by the span-enrichment capture hook (see + // SpanEnrichmentHook). Emitted only when the span-enrichment gate is on. static final String METADATA_SPLIT_SERIAL_ID = "__dd_split_serial_id"; static final String METADATA_DO_LOG = "__dd_do_log"; - static final String METADATA_EVAL_TIMESTAMP_MS = "__dd_eval_timestamp_ms"; // Stamped on every DD-produced evaluation (including PROVIDER_NOT_READY, with false). Missing // key = non-DD provider; the hook falls back to false (fail-closed). @@ -111,11 +111,17 @@ class DDEvaluator implements Evaluator, FeatureFlaggingGateway.ConfigListener { private static final boolean SPAN_ENRICHMENT_ENABLED = SpanEnrichmentGate.isEnabled(); private final Runnable configCallback; + private final boolean telemetryEnabled; private final AtomicReference configuration = new AtomicReference<>(); private final CountDownLatch initializationLatch = new CountDownLatch(1); public DDEvaluator(final Runnable configCallback) { + this(configCallback, true); + } + + public DDEvaluator(final Runnable configCallback, final boolean telemetryEnabled) { this.configCallback = configCallback; + this.telemetryEnabled = telemetryEnabled; } @Override @@ -218,11 +224,13 @@ public ProviderEvaluation evaluate( if (isEmpty(split.shards)) { return resolveVariant( target, + key, defaultValue, flag, split.variationKey, allocation, split, + context, evalTimestampMs, observeFullEvaluationData); } else { @@ -241,11 +249,13 @@ public ProviderEvaluation evaluate( if (allShardsMatch) { return resolveVariant( target, + key, defaultValue, flag, split.variationKey, allocation, split, + context, evalTimestampMs, observeFullEvaluationData); } @@ -491,13 +501,15 @@ private static String getMD5Hash(final String input) { } } - private static ProviderEvaluation resolveVariant( + private ProviderEvaluation resolveVariant( final Class target, + final String key, final T defaultValue, final Flag flag, final String variationKey, final Allocation allocation, final Split split, + final EvaluationContext context, final long evalTimestampMs, final boolean observeFullEvaluationData) { final Variant variant = flag.variations.get(variationKey); @@ -538,33 +550,41 @@ private static ProviderEvaluation resolveVariant( // Stamp eval-time at the resolution point so first/last_evaluation reflect evaluation time, // not hook-fire time. Passed to the hook via provider metadata "__dd_eval_timestamp_ms". - final boolean doLog = allocation.doLog != null && allocation.doLog; final ImmutableMetadata.ImmutableMetadataBuilder metadataBuilder = ImmutableMetadata.builder() .addString("flagKey", flag.key) .addString("variationType", flag.variationType.name()) .addString("allocationKey", allocation.key) - .addLong(METADATA_EVAL_TIMESTAMP_MS, evalTimestampMs) - .addBoolean(METADATA_OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData) - .addBoolean(METADATA_DO_LOG, doLog); - // The exposure hook always needs doLog. The split serial id remains conditional on span - // enrichment so a provider without enrichment does not attach unused serial-id metadata. + .addLong("__dd_eval_timestamp_ms", evalTimestampMs) + .addBoolean(METADATA_OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData); + // Surface the UFC split's serial id and the allocation's doLog flag for APM span enrichment — + // only when span enrichment is on, so a provider without enrichment pays nothing extra. + // __dd_split_serial_id is omitted when the split carries no serial id; __dd_do_log is always + // present (when enrichment is on) so the span-enrichment hook can decide whether to record the + // subject. if (SPAN_ENRICHMENT_ENABLED) { if (split.serialId != null) { metadataBuilder.addInteger(METADATA_SPLIT_SERIAL_ID, split.serialId); } + metadataBuilder.addBoolean(METADATA_DO_LOG, allocation.doLog != null && allocation.doLog); + } + final ProviderEvaluation result = + ProviderEvaluation.builder() + .value(mappedValue) + .reason( + !isEmpty(allocation.rules) + ? Reason.TARGETING_MATCH.name() + : allocation.startAt != null || allocation.endAt != null + ? Reason.DEFAULT.name() + : !isEmpty(split.shards) ? Reason.SPLIT.name() : Reason.STATIC.name()) + .variant(variant.key) + .flagMetadata(metadataBuilder.build()) + .build(); + final boolean doLog = allocation.doLog != null && allocation.doLog; + if (telemetryEnabled && doLog) { + dispatchExposure(key, result, context); } - return ProviderEvaluation.builder() - .value(mappedValue) - .reason( - !isEmpty(allocation.rules) - ? Reason.TARGETING_MATCH.name() - : allocation.startAt != null || allocation.endAt != null - ? Reason.DEFAULT.name() - : !isEmpty(split.shards) ? Reason.SPLIT.name() : Reason.STATIC.name()) - .variant(variant.key) - .flagMetadata(metadataBuilder.build()) - .build(); + return result; } private static Object resolveAttribute(final String name, final EvaluationContext context) { @@ -634,6 +654,29 @@ private static Double parseDouble(final Object value) { return Double.parseDouble(String.valueOf(value)); } + private static void dispatchExposure( + final String flag, final ProviderEvaluation evaluation, final EvaluationContext context) { + final String allocationKey = allocationKey(evaluation); + final String variantKey = evaluation.getVariant(); + if (allocationKey == null || variantKey == null) { + return; + } + final ExposureEvent event = + new ExposureEvent( + System.currentTimeMillis(), + new datadog.trace.api.featureflag.exposure.Allocation(allocationKey), + new datadog.trace.api.featureflag.exposure.Flag(flag), + new datadog.trace.api.featureflag.exposure.Variant(variantKey), + new Subject(context.getTargetingKey(), flattenContext(context))); + + FeatureFlaggingGateway.dispatch(event); + } + + private static String allocationKey(final ProviderEvaluation resolution) { + final ImmutableMetadata meta = resolution.getFlagMetadata(); + return meta == null ? null : meta.getString("allocationKey"); + } + static AbstractMap flattenContext(final EvaluationContext context) { return flattenValues(snapshotValues(context)); } diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ExposureHook.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ExposureHook.java deleted file mode 100644 index 4c7f30295cd..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/ExposureHook.java +++ /dev/null @@ -1,54 +0,0 @@ -package datadog.trace.api.openfeature; - -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.exposure.ExposureEvent; -import datadog.trace.api.featureflag.exposure.Subject; -import dev.openfeature.sdk.EvaluationContext; -import dev.openfeature.sdk.FlagEvaluationDetails; -import dev.openfeature.sdk.Hook; -import dev.openfeature.sdk.HookContext; -import dev.openfeature.sdk.ImmutableMetadata; -import java.util.Map; - -/** OpenFeature hook that dispatches exposure events for allocations with {@code doLog=true}. */ -class ExposureHook implements Hook { - - static final ExposureHook INSTANCE = new ExposureHook<>(); - - @Override - public void after( - final HookContext context, - final FlagEvaluationDetails details, - final Map hints) { - try { - if (details == null) { - return; - } - final ImmutableMetadata metadata = details.getFlagMetadata(); - if (metadata == null - || !Boolean.TRUE.equals(metadata.getBoolean(DDEvaluator.METADATA_DO_LOG))) { - return; - } - final String allocationKey = metadata.getString("allocationKey"); - final String variantKey = details.getVariant(); - final EvaluationContext evaluationContext = context != null ? context.getCtx() : null; - if (allocationKey == null || variantKey == null || evaluationContext == null) { - return; - } - final Long evaluationTimestamp = metadata.getLong(DDEvaluator.METADATA_EVAL_TIMESTAMP_MS); - final long timestamp = - evaluationTimestamp != null ? evaluationTimestamp : System.currentTimeMillis(); - FeatureFlaggingGateway.dispatch( - new ExposureEvent( - timestamp, - new datadog.trace.api.featureflag.exposure.Allocation(allocationKey), - new datadog.trace.api.featureflag.exposure.Flag(details.getFlagKey()), - new datadog.trace.api.featureflag.exposure.Variant(variantKey), - new Subject( - evaluationContext.getTargetingKey(), - DDEvaluator.flattenContext(evaluationContext)))); - } catch (final LinkageError | Exception ignored) { - // Never let exposure recording break flag evaluation. - } - } -} diff --git a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalLoggingHook.java b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalLoggingHook.java index d538c560fba..322f11ac9e1 100644 --- a/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalLoggingHook.java +++ b/products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalLoggingHook.java @@ -102,8 +102,7 @@ public void finallyAfter( // eval-time: from flag metadata "__dd_eval_timestamp_ms" (Long), fallback to hook-fire time. // ImmutableMetadata.getLong available since sdk 1.4+. - final Long evalTimeObj = - metadata != null ? metadata.getLong(DDEvaluator.METADATA_EVAL_TIMESTAMP_MS) : null; + final Long evalTimeObj = metadata != null ? metadata.getLong("__dd_eval_timestamp_ms") : null; final long evalTimeMs = evalTimeObj != null ? evalTimeObj : System.currentTimeMillis(); // variant: the OpenFeature variant key (same source as the OTel FlagEvalMetricsHook), NOT the 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 95b2a09a35b..e85350a2bc7 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 @@ -32,6 +32,7 @@ public class Provider extends EventProvider implements Metadata { private static final long DEFAULT_INIT_TIMEOUT = 30; private volatile Evaluator evaluator; private final Options options; + private final boolean telemetryEnabled; private final AtomicReference initializationState = new AtomicReference<>(InitializationState.NOT_STARTED); private final FlagEvalMetrics flagEvalMetrics; @@ -63,10 +64,11 @@ public Provider(final Options options) { final Evaluator evaluator, final Boolean spanEnrichmentEnabledOverride) { this.options = options; + this.telemetryEnabled = options.isTelemetryEnabled(); this.evaluator = evaluator; FlagEvalMetrics metrics = null; FlagEvalMetricsHook hook = null; - if (options.isTelemetryEnabled()) { + if (telemetryEnabled) { try { metrics = new FlagEvalMetrics(); hook = new FlagEvalMetricsHook(metrics); @@ -81,7 +83,7 @@ public Provider(final Options options) { // Span enrichment is wired ONLY when the gate is on — off means no capture hook and no idle // per-evaluation overhead. final boolean spanEnrichmentEnabled = - options.isTelemetryEnabled() + telemetryEnabled && (spanEnrichmentEnabledOverride != null ? spanEnrichmentEnabledOverride : SpanEnrichmentGate.isEnabled()); @@ -89,22 +91,13 @@ public Provider(final Options options) { // Precompute the immutable hook list once so getProviderHooks() (called on every evaluation) // allocates nothing, including when the gate is off. - final List hooks = new ArrayList<>(4); + final List hooks = new ArrayList<>(3); if (flagEvalMetricsHook != null) { hooks.add(flagEvalMetricsHook); } - if (options.isTelemetryEnabled()) { - // Exposure and EVP flag-evaluation hooks are always registered when provider telemetry is - // enabled. Each is a no-op when its agent-side listener or writer is absent. - try { - final Hook exposureHook = buildExposureHook(); - if (exposureHook != null) { - hooks.add(exposureHook); - } - } catch (LinkageError | Exception e) { - // Keep older bootstrap/API combinations working: exposure recording is best-effort. - } - // Writer is resolved lazily from FeatureFlaggingGateway.getFlagEvalWriter() on each call. + if (telemetryEnabled) { + // EVP flagevaluation hook: registered when provider telemetry is enabled; no-op when the + // writer is absent (killswitch off). The writer is resolved lazily on each call. try { final Hook flagEvalLoggingHook = buildFlagEvalLoggingHook(); if (flagEvalLoggingHook != null) { @@ -230,8 +223,8 @@ private Evaluator buildEvaluator() throws Exception { return evaluator; } final Class evaluatorClass = loadEvaluatorClass(); - final Constructor ctor = evaluatorClass.getConstructor(Runnable.class); - return (Evaluator) ctor.newInstance((Runnable) this::onConfigurationChange); + final Constructor ctor = evaluatorClass.getConstructor(Runnable.class, boolean.class); + return (Evaluator) ctor.newInstance((Runnable) this::onConfigurationChange, telemetryEnabled); } @Override @@ -243,10 +236,6 @@ Hook buildFlagEvalLoggingHook() { return FlagEvalLoggingHook.INSTANCE; } - Hook buildExposureHook() { - return ExposureHook.INSTANCE; - } - @Override public void shutdown() { if (flagEvalMetrics != null) { 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 1450217d899..2f04dd0d32f 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 @@ -13,6 +13,7 @@ import static org.hamcrest.Matchers.greaterThan; import static org.hamcrest.Matchers.hasEntry; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; @@ -26,6 +27,7 @@ import com.squareup.moshi.Moshi; import com.squareup.moshi.Types; import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import datadog.trace.api.featureflag.exposure.ExposureEvent; import datadog.trace.api.featureflag.ufc.v1.Allocation; import datadog.trace.api.featureflag.ufc.v1.ConditionConfiguration; import datadog.trace.api.featureflag.ufc.v1.ConditionOperator; @@ -59,6 +61,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; import java.util.stream.Stream; import org.junit.jupiter.api.Test; @@ -299,15 +302,19 @@ public void observeFullEvaluationDataStampedFalseOnResolvedVariant() { } @Test - public void doLogIsStampedOnResolvedVariantForExposureHook() { - assertThat( - evaluateMatchingFlag(false, true).getFlagMetadata().getBoolean(DDEvaluator.METADATA_DO_LOG), - equalTo(true)); - assertThat( - evaluateMatchingFlag(false, false) - .getFlagMetadata() - .getBoolean(DDEvaluator.METADATA_DO_LOG), - equalTo(false)); + public void telemetryDisabledSuppressesExposures() { + final AtomicReference exposure = new AtomicReference<>(); + final FeatureFlaggingGateway.ExposureListener listener = exposure::set; + FeatureFlaggingGateway.addExposureListener(listener); + try { + evaluateMatchingFlag(false, true, false); + assertNull(exposure.get()); + + evaluateMatchingFlag(false, true, true); + assertNotNull(exposure.get()); + } finally { + FeatureFlaggingGateway.removeExposureListener(listener); + } } // -- DISABLED path: flag.enabled=false -- @@ -416,6 +423,13 @@ private static ProviderEvaluation evaluateMatchingFlag( private static ProviderEvaluation evaluateMatchingFlag( final boolean observeFullEvaluationData, final boolean doLog) { + return evaluateMatchingFlag(observeFullEvaluationData, doLog, true); + } + + private static ProviderEvaluation evaluateMatchingFlag( + final boolean observeFullEvaluationData, + final boolean doLog, + final boolean telemetryEnabled) { final Map variations = new HashMap<>(); variations.put("on", new Variant("on", 1)); final Split split = new Split(emptyList(), "on", emptyMap(), null); @@ -423,7 +437,8 @@ private static ProviderEvaluation evaluateMatchingFlag( new Allocation("alloc-1", null, null, null, singletonList(split), doLog); return evaluateFlag( new Flag("target", true, ValueType.INTEGER, variations, singletonList(allocation)), - observeFullEvaluationData); + observeFullEvaluationData, + telemetryEnabled); } private static ProviderEvaluation evaluateDisabledFlag( @@ -445,9 +460,14 @@ private static ProviderEvaluation evaluateWithEmptySplits( private static ProviderEvaluation evaluateFlag( final Flag flag, final boolean observeFullEvaluationData) { + return evaluateFlag(flag, observeFullEvaluationData, true); + } + + private static ProviderEvaluation evaluateFlag( + final Flag flag, final boolean observeFullEvaluationData, final boolean telemetryEnabled) { final Map flags = new HashMap<>(); flags.put("target", flag); - final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class), telemetryEnabled); evaluator.accept(new ServerConfiguration("", "", observeFullEvaluationData, null, flags)); final EvaluationContext ctx = new MutableContext("target").setTargetingKey("user-1"); diff --git a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExposureHookTest.java b/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExposureHookTest.java deleted file mode 100644 index b2414271ebd..00000000000 --- a/products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/ExposureHookTest.java +++ /dev/null @@ -1,103 +0,0 @@ -package datadog.trace.api.openfeature; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.exposure.ExposureEvent; -import dev.openfeature.sdk.FlagEvaluationDetails; -import dev.openfeature.sdk.FlagValueType; -import dev.openfeature.sdk.HookContext; -import dev.openfeature.sdk.ImmutableMetadata; -import dev.openfeature.sdk.MutableContext; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -class ExposureHookTest { - - private static final long EVALUATION_TIMESTAMP = 1_700_000_000_000L; - - private final List captured = new ArrayList<>(); - private final FeatureFlaggingGateway.ExposureListener listener = captured::add; - - @BeforeEach - void register() { - FeatureFlaggingGateway.addExposureListener(listener); - } - - @AfterEach - void deregister() { - FeatureFlaggingGateway.removeExposureListener(listener); - } - - @Test - void afterDispatchesExposureWhenDoLogIsTrue() { - final MutableContext evaluationContext = new MutableContext("user-1"); - evaluationContext.add("region", "us-east-1"); - - ExposureHook.INSTANCE.after( - hookContext(evaluationContext), details(true), Collections.emptyMap()); - - assertEquals(1, captured.size()); - final ExposureEvent exposure = captured.get(0); - assertEquals(EVALUATION_TIMESTAMP, exposure.timestamp); - assertEquals("allocation-1", exposure.allocation.key); - assertEquals("my-flag", exposure.flag.key); - assertEquals("on", exposure.variant.key); - assertEquals("user-1", exposure.subject.id); - assertEquals("us-east-1", exposure.subject.attributes.get("region")); - } - - @Test - void afterDoesNotDispatchExposureWhenDoLogIsFalse() { - ExposureHook.INSTANCE.after( - hookContext(new MutableContext("user-1")), details(false), Collections.emptyMap()); - - assertTrue(captured.isEmpty()); - } - - @Test - void afterDoesNotDispatchExposureWithoutDatadogMetadata() { - final FlagEvaluationDetails details = - FlagEvaluationDetails.builder().flagKey("my-flag").value("value").variant("on").build(); - - ExposureHook.INSTANCE.after( - hookContext(new MutableContext("user-1")), details, Collections.emptyMap()); - - assertTrue(captured.isEmpty()); - } - - @Test - void afterHandlesNullDetails() { - ExposureHook.INSTANCE.after(null, null, null); - - assertTrue(captured.isEmpty()); - } - - private static HookContext hookContext(final MutableContext evaluationContext) { - return HookContext.builder() - .flagKey("my-flag") - .type(FlagValueType.STRING) - .defaultValue("default") - .ctx(evaluationContext) - .build(); - } - - private static FlagEvaluationDetails details(final boolean doLog) { - return FlagEvaluationDetails.builder() - .flagKey("my-flag") - .value("value") - .variant("on") - .flagMetadata( - ImmutableMetadata.builder() - .addString("allocationKey", "allocation-1") - .addBoolean(DDEvaluator.METADATA_DO_LOG, doLog) - .addLong(DDEvaluator.METADATA_EVAL_TIMESTAMP_MS, EVALUATION_TIMESTAMP) - .build()) - .build(); - } -} 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 bd40656429f..fb669e45ff0 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 @@ -6,12 +6,10 @@ import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; @@ -20,8 +18,6 @@ import static org.mockito.Mockito.when; import datadog.trace.api.featureflag.FeatureFlaggingGateway; -import datadog.trace.api.featureflag.SpanEnrichmentEvent; -import datadog.trace.api.featureflag.exposure.ExposureEvent; import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; import datadog.trace.api.featureflag.flagevaluation.FlagEvaluationWriter; import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; @@ -329,7 +325,6 @@ public void testGetProviderHooksReturnsTelemetryHooks() { new Provider(new Options().initTimeout(10, MILLISECONDS), mock(Evaluator.class)); assertHasHook(provider, FlagEvalMetricsHook.class); - assertHasHook(provider, ExposureHook.class); assertHasHook(provider, FlagEvalLoggingHook.class); } @@ -344,7 +339,6 @@ Hook buildFlagEvalLoggingHook() { }; assertHasHook(provider, FlagEvalMetricsHook.class); - assertHasHook(provider, ExposureHook.class); assertFalse( provider.getProviderHooks().stream().anyMatch(FlagEvalLoggingHook.class::isInstance)); } @@ -367,65 +361,6 @@ public void testOptionsRetainDefaultTimeoutWhenOnlyTelemetryIsConfigured() { assertFalse(options.isTelemetryEnabled()); } - @Test - public void testNamedDomainsCanSeparateLiveAndPeekEvaluations() throws Exception { - final AtomicReference flagEvaluation = new AtomicReference<>(); - final AtomicReference exposure = new AtomicReference<>(); - final AtomicReference spanEnrichment = new AtomicReference<>(); - final FeatureFlaggingGateway.ExposureListener exposureListener = exposure::set; - final FeatureFlaggingGateway.SpanEnrichmentListener spanEnrichmentListener = - spanEnrichment::set; - FeatureFlaggingGateway.setFlagEvalWriter(capturingWriter(flagEvaluation)); - FeatureFlaggingGateway.addExposureListener(exposureListener); - FeatureFlaggingGateway.addSpanEnrichmentListener(spanEnrichmentListener); - try { - final Evaluator evaluator = mock(Evaluator.class); - when(evaluator.initialize(anyLong(), any(), any())).thenReturn(true); - when(evaluator.hasConfiguration()).thenReturn(true); - when(evaluator.evaluate(eq(String.class), eq("my-flag"), eq("default"), any())) - .thenReturn( - ProviderEvaluation.builder() - .value("value") - .reason("STATIC") - .variant("on") - .flagMetadata( - ImmutableMetadata.builder() - .addString("allocationKey", "allocation-1") - .addLong(DDEvaluator.METADATA_EVAL_TIMESTAMP_MS, 1_700_000_000_000L) - .addBoolean(DDEvaluator.METADATA_DO_LOG, true) - .addBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA, true) - .addInteger(DDEvaluator.METADATA_SPLIT_SERIAL_ID, 42) - .build()) - .build()); - - final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); - api.setProviderAndWait("live", new Provider(new Options(), evaluator, Boolean.TRUE)); - api.setProviderAndWait( - "peek", new Provider(new Options().telemetryEnabled(false), evaluator, Boolean.TRUE)); - - final MutableContext context = new MutableContext("user-1"); - context.add("region", "us-east-1"); - final FlagEvaluationDetails peekDetails = - api.getClient("peek").getStringDetails("my-flag", "default", context); - - assertThat(peekDetails.getValue(), equalTo("value")); - assertNull(exposure.get()); - assertNull(flagEvaluation.get()); - assertNull(spanEnrichment.get()); - - final FlagEvaluationDetails liveDetails = - api.getClient("live").getStringDetails("my-flag", "default", context); - - assertThat(liveDetails.getValue(), equalTo("value")); - assertNotNull(exposure.get()); - assertNotNull(flagEvaluation.get()); - assertNotNull(spanEnrichment.get()); - } finally { - FeatureFlaggingGateway.removeExposureListener(exposureListener); - FeatureFlaggingGateway.removeSpanEnrichmentListener(spanEnrichmentListener); - } - } - @Test public void testClientEvaluationRoutesThroughFlagEvalLoggingHook() throws Exception { FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); @@ -490,7 +425,6 @@ public void testShutdownCleansUpEvaluator() throws Exception { verify(evaluator).shutdown(); assertHasHook(provider, FlagEvalMetricsHook.class); - assertHasHook(provider, ExposureHook.class); assertHasHook(provider, FlagEvalLoggingHook.class); } From e2edf835ce2ec04032aa38b4718bce75f7f94977 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 3 Sep 2026 03:42:09 +0000 Subject: [PATCH 3/6] Share OpenFeature configuration across providers Move the process-wide configuration snapshot into the gateway so providers cannot diverge during concurrent registration. Environment: Datadog workspace --- .../FeatureFlaggingSystemTest.java | 2 + .../feature-flagging-api/README.md | 5 +- .../trace/api/openfeature/DDEvaluator.java | 20 ++- .../trace/api/openfeature/Provider.java | 48 +++--- .../api/openfeature/DDEvaluatorTest.java | 140 ++++++++++++++++-- .../featureflag/FeatureFlaggingGateway.java | 35 ++++- .../FeatureFlaggingGatewayTest.java | 19 +++ 7 files changed, 216 insertions(+), 53 deletions(-) diff --git a/products/feature-flagging/feature-flagging-agent/src/test/java/com/datadog/featureflag/FeatureFlaggingSystemTest.java b/products/feature-flagging/feature-flagging-agent/src/test/java/com/datadog/featureflag/FeatureFlaggingSystemTest.java index 5b88389aefc..4a7c31a9826 100644 --- a/products/feature-flagging/feature-flagging-agent/src/test/java/com/datadog/featureflag/FeatureFlaggingSystemTest.java +++ b/products/feature-flagging/feature-flagging-agent/src/test/java/com/datadog/featureflag/FeatureFlaggingSystemTest.java @@ -160,6 +160,8 @@ void testFeatureFlagSystemInitialization() { FeatureFlaggingSystem.start(sharedCommunicationObjects); FeatureFlaggingSystem.start(sharedCommunicationObjects); + FeatureFlaggingGateway.activate(); + FeatureFlaggingGateway.activate(); verify(poller).addCapabilities(Capabilities.CAPABILITY_FFE_FLAG_CONFIGURATION_RULES); verify(poller).addListener(eq(Product.FFE_FLAGS), any(ConfigurationDeserializer.class), any()); diff --git a/products/feature-flagging/feature-flagging-api/README.md b/products/feature-flagging/feature-flagging-api/README.md index 1e0d27e5213..ca01d2e6a4d 100644 --- a/products/feature-flagging/feature-flagging-api/README.md +++ b/products/feature-flagging/feature-flagging-api/README.md @@ -79,14 +79,15 @@ EvaluationContext analyticsContext = new MutableContext("user-123"); boolean checkoutEnabled = checkoutClient.getBooleanValue( "my-feature", false, checkoutContext); -// Returns the same evaluation result without emitting Datadog telemetry. +// Evaluates against the shared current configuration without emitting Datadog telemetry. boolean analyticsEnabled = analyticsClient.getBooleanValue( "my-feature", false, analyticsContext); ``` `telemetryEnabled(false)` suppresses exposures, EVP flag-evaluation events, OpenTelemetry evaluation metrics, and APM span enrichment for that provider. It does not disable evaluation or -change the configuration used to resolve flags. +change the configuration used to resolve flags. All provider domains read the same process-wide +configuration snapshot and share one configuration request path. ## Evaluation metrics 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 3b69215ea77..88a4cfd3bf9 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 @@ -3,6 +3,7 @@ import static java.util.Arrays.asList; import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import datadog.trace.api.featureflag.FeatureFlaggingGateway.ConfigSnapshot; import datadog.trace.api.featureflag.exposure.ExposureEvent; import datadog.trace.api.featureflag.exposure.Subject; import datadog.trace.api.featureflag.ufc.v1.Allocation; @@ -43,7 +44,6 @@ import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; @@ -112,8 +112,8 @@ class DDEvaluator implements Evaluator, FeatureFlaggingGateway.ConfigListener { private final Runnable configCallback; private final boolean telemetryEnabled; - private final AtomicReference configuration = new AtomicReference<>(); private final CountDownLatch initializationLatch = new CountDownLatch(1); + private long lastConfigVersion; public DDEvaluator(final Runnable configCallback) { this(configCallback, true); @@ -134,7 +134,7 @@ public boolean initialize( @Override public boolean hasConfiguration() { - return configuration.get() != null; + return FeatureFlaggingGateway.getConfigSnapshot().getConfig() != null; } @Override @@ -143,8 +143,15 @@ public void shutdown() { } @Override - public void accept(final ServerConfiguration config) { - configuration.set(config); + public synchronized void accept(final ServerConfiguration ignored) { + // Listener callbacks are notifications only. Always read the process-wide snapshot so a stale + // register-and-replay callback cannot restore an older configuration in this evaluator. + final ConfigSnapshot snapshot = FeatureFlaggingGateway.getConfigSnapshot(); + if (snapshot.getVersion() <= lastConfigVersion) { + return; + } + lastConfigVersion = snapshot.getVersion(); + final ServerConfiguration config = snapshot.getConfig(); if (config != null) { initializationLatch.countDown(); configCallback.run(); @@ -162,7 +169,8 @@ public ProviderEvaluation evaluate( // Snapshot the config once and thread observeFullEvaluationData through every // ProviderEvaluation returned, so the hook's consent decision is pinned to this evaluation's // config and cannot drift on a concurrent Remote Config swap. - final ServerConfiguration config = configuration.get(); + final ConfigSnapshot snapshot = FeatureFlaggingGateway.getConfigSnapshot(); + final ServerConfiguration config = snapshot.getConfig(); // Boolean.TRUE.equals covers both null (privacy-preserving default) and Boolean.FALSE without // an NPE — the field is boxed so a malformed UFC message doesn't abort the whole parse. final boolean observeFullEvaluationData = 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 e85350a2bc7..adc9ad949c6 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 @@ -66,36 +66,21 @@ public Provider(final Options options) { this.options = options; this.telemetryEnabled = options.isTelemetryEnabled(); this.evaluator = evaluator; + FlagEvalMetrics metrics = null; - FlagEvalMetricsHook hook = null; + FlagEvalMetricsHook metricsHook = null; + SpanEnrichmentHook enrichmentHook = null; + final List hooks = new ArrayList<>(3); if (telemetryEnabled) { try { metrics = new FlagEvalMetrics(); - hook = new FlagEvalMetricsHook(metrics); + metricsHook = new FlagEvalMetricsHook(metrics); + hooks.add(metricsHook); } catch (LinkageError | Exception e) { // This outer catch fires when the metrics helper itself can't load (OTel API absent). log.warn("Evaluation metrics unavailable — OTel API classes not on classpath", e); } - } - this.flagEvalMetrics = metrics; - this.flagEvalMetricsHook = hook; - - // Span enrichment is wired ONLY when the gate is on — off means no capture hook and no idle - // per-evaluation overhead. - final boolean spanEnrichmentEnabled = - telemetryEnabled - && (spanEnrichmentEnabledOverride != null - ? spanEnrichmentEnabledOverride - : SpanEnrichmentGate.isEnabled()); - this.spanEnrichmentHook = spanEnrichmentEnabled ? new SpanEnrichmentHook() : null; - // Precompute the immutable hook list once so getProviderHooks() (called on every evaluation) - // allocates nothing, including when the gate is off. - final List hooks = new ArrayList<>(3); - if (flagEvalMetricsHook != null) { - hooks.add(flagEvalMetricsHook); - } - if (telemetryEnabled) { // EVP flagevaluation hook: registered when provider telemetry is enabled; no-op when the // writer is absent (killswitch off). The writer is resolved lazily on each call. try { @@ -106,10 +91,25 @@ public Provider(final Options options) { } catch (LinkageError | Exception e) { // Keep older bootstrap/API combinations working: EVP recording is best-effort. } + + // Span enrichment is wired ONLY when the gate is on — off means no capture hook and no idle + // per-evaluation overhead. + final boolean spanEnrichmentEnabled = + spanEnrichmentEnabledOverride != null + ? spanEnrichmentEnabledOverride + : SpanEnrichmentGate.isEnabled(); + enrichmentHook = spanEnrichmentEnabled ? new SpanEnrichmentHook() : null; + if (enrichmentHook != null) { + hooks.add(enrichmentHook); + } } - if (spanEnrichmentHook != null) { - hooks.add(spanEnrichmentHook); - } + + this.flagEvalMetrics = metrics; + this.flagEvalMetricsHook = metricsHook; + this.spanEnrichmentHook = enrichmentHook; + + // Precompute the immutable hook list once so getProviderHooks() (called on every evaluation) + // allocates nothing, including when telemetry is disabled. this.providerHooks = hooks.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(hooks); 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 2f04dd0d32f..460b4fbf0d0 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 @@ -58,12 +58,15 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; import java.util.stream.Stream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -89,6 +92,12 @@ public class DDEvaluatorTest { ThreadLocal.withInitial(HashMap::new); private static final long MAX_UNSIGNED_INT = 0xffff_ffffL; + @BeforeEach + @AfterEach + void clearCurrentConfiguration() { + FeatureFlaggingGateway.dispatch((ServerConfiguration) null); + } + @Test public void testInitializeSignalsApplicationProviderActivation() throws Exception { final FeatureFlaggingGateway.ActivationListener listener = @@ -176,7 +185,7 @@ public void testEvaluateNoConfig() { public void testInitializeTimesOutWithoutConfig() throws Exception { final Runnable configCallback = mock(Runnable.class); final DDEvaluator evaluator = new DDEvaluator(configCallback); - evaluator.accept(null); + FeatureFlaggingGateway.dispatch((ServerConfiguration) null); try { assertThat( evaluator.initialize(10, MILLISECONDS, mock(EvaluationContext.class)), equalTo(false)); @@ -194,10 +203,10 @@ public void testInitializeWaitsForNonNullConfig() throws Exception { final Future initialized = executor.submit(() -> evaluator.initialize(1, SECONDS, mock(EvaluationContext.class))); - evaluator.accept(null); + FeatureFlaggingGateway.dispatch((ServerConfiguration) null); assertThat(initialized.isDone(), equalTo(false)); - evaluator.accept(mock(ServerConfiguration.class)); + FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); assertThat(initialized.get(1, SECONDS), equalTo(true)); } finally { executor.shutdownNow(); @@ -205,10 +214,109 @@ public void testInitializeWaitsForNonNullConfig() throws Exception { } } + @Test + public void testConcurrentRegistrationCannotRestoreStaleConfiguration() throws Exception { + final ServerConfiguration firstConfiguration = + new ServerConfiguration("first", "", false, null, emptyMap()); + final ServerConfiguration secondConfiguration = + new ServerConfiguration("second", "", true, null, emptyMap()); + final CountDownLatch staleReplayStarted = new CountDownLatch(1); + final CountDownLatch allowStaleReplay = new CountDownLatch(1); + final DDEvaluator evaluator = + new DDEvaluator(mock(Runnable.class)) { + @Override + public void accept(final ServerConfiguration configuration) { + if (configuration == firstConfiguration) { + staleReplayStarted.countDown(); + try { + if (!allowStaleReplay.await(5, SECONDS)) { + throw new AssertionError( + "Timed out waiting to resume stale configuration replay"); + } + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError( + "Interrupted while waiting to replay stale configuration", e); + } + } + super.accept(configuration); + } + }; + final ExecutorService executor = Executors.newSingleThreadExecutor(); + + FeatureFlaggingGateway.dispatch(firstConfiguration); + try { + final Future registration = + executor.submit(() -> FeatureFlaggingGateway.addConfigListener(evaluator)); + + assertThat(staleReplayStarted.await(5, SECONDS), equalTo(true)); + FeatureFlaggingGateway.dispatch(secondConfiguration); + allowStaleReplay.countDown(); + registration.get(5, SECONDS); + + final ProviderEvaluation details = + evaluator.evaluate(Integer.class, "missing", 23, mock(EvaluationContext.class)); + + assertThat( + details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA), + equalTo(true)); + } finally { + allowStaleReplay.countDown(); + evaluator.shutdown(); + FeatureFlaggingGateway.dispatch((ServerConfiguration) null); + executor.shutdownNow(); + } + } + + @Test + public void testEvaluatorsReadSameProcessWideConfiguration() { + final DDEvaluator firstEvaluator = new DDEvaluator(mock(Runnable.class)); + final DDEvaluator secondEvaluator = new DDEvaluator(mock(Runnable.class)); + + FeatureFlaggingGateway.dispatch(new ServerConfiguration("first", "", false, null, emptyMap())); + assertThat(observeFullEvaluationData(firstEvaluator), equalTo(false)); + assertThat(observeFullEvaluationData(secondEvaluator), equalTo(false)); + + FeatureFlaggingGateway.dispatch(new ServerConfiguration("second", "", true, null, emptyMap())); + assertThat(observeFullEvaluationData(firstEvaluator), equalTo(true)); + assertThat(observeFullEvaluationData(secondEvaluator), equalTo(true)); + } + + @Test + public void testUnavailableConfigurationIsVisibleToAllEvaluators() { + final DDEvaluator firstEvaluator = new DDEvaluator(mock(Runnable.class)); + final DDEvaluator secondEvaluator = new DDEvaluator(mock(Runnable.class)); + final EvaluationContext context = mock(EvaluationContext.class); + + FeatureFlaggingGateway.dispatch( + new ServerConfiguration("available", "", false, null, emptyMap())); + assertThat( + firstEvaluator.evaluate(Integer.class, "missing", 23, context).getErrorCode(), + equalTo(ErrorCode.FLAG_NOT_FOUND)); + assertThat( + secondEvaluator.evaluate(Integer.class, "missing", 23, context).getErrorCode(), + equalTo(ErrorCode.FLAG_NOT_FOUND)); + + FeatureFlaggingGateway.dispatch((ServerConfiguration) null); + assertThat( + firstEvaluator.evaluate(Integer.class, "missing", 23, context).getErrorCode(), + equalTo(ErrorCode.PROVIDER_NOT_READY)); + assertThat( + secondEvaluator.evaluate(Integer.class, "missing", 23, context).getErrorCode(), + equalTo(ErrorCode.PROVIDER_NOT_READY)); + } + + private static Boolean observeFullEvaluationData(final DDEvaluator evaluator) { + return evaluator + .evaluate(Integer.class, "missing", 23, mock(EvaluationContext.class)) + .getFlagMetadata() + .getBoolean(DDEvaluator.METADATA_OBSERVE_FULL_EVALUATION_DATA); + } + @Test public void testEvaluateNoContext() { final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(mock(ServerConfiguration.class)); + FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); final ProviderEvaluation details = evaluator.evaluate(Integer.class, "test", 23, null); assertThat(details.getValue(), equalTo(23)); assertThat(details.getReason(), equalTo(ERROR.name())); @@ -221,7 +329,7 @@ public void testNoAllocations() { flags.put("null-allocation", new Flag("target", true, null, null, null)); flags.put("empty-allocation", new Flag("target", true, null, null, emptyList())); final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", false, null, flags)); + FeatureFlaggingGateway.dispatch(new ServerConfiguration("", "", false, null, flags)); final EvaluationContext ctx = new MutableContext("target").setTargetingKey("allocation"); @@ -255,7 +363,7 @@ public void testEvaluateUnsignedShardRange() { "target", new Flag("target", true, ValueType.INTEGER, variations, singletonList(allocation))); final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", false, null, flags)); + FeatureFlaggingGateway.dispatch(new ServerConfiguration("", "", false, null, flags)); final EvaluationContext context = new MutableContext("target").setTargetingKey("high-shard-user"); @@ -370,7 +478,7 @@ public void observeFullEvaluationDataStampedOnFlagNotFoundError() { // Was previously named "…OnSuccess" but actually exercises the error() helper's stamp via // FLAG_NOT_FOUND — kept for that stamp site, correctly named. final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", true, null, new HashMap<>())); + FeatureFlaggingGateway.dispatch(new ServerConfiguration("", "", true, null, new HashMap<>())); final EvaluationContext ctx = new MutableContext("target").setTargetingKey("k"); final ProviderEvaluation details = @@ -401,7 +509,7 @@ public void observeFullEvaluationDataNullConfigFieldTreatedAsFalse() { final Map flags = new HashMap<>(); flags.put("target", new Flag("target", true, ValueType.INTEGER, emptyMap(), emptyList())); final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", null, null, flags)); + FeatureFlaggingGateway.dispatch(new ServerConfiguration("", "", null, null, flags)); final EvaluationContext ctx = new MutableContext("target").setTargetingKey("k"); final ProviderEvaluation details = evaluator.evaluate(Integer.class, "target", 23, ctx); @@ -468,7 +576,8 @@ private static ProviderEvaluation evaluateFlag( final Map flags = new HashMap<>(); flags.put("target", flag); final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class), telemetryEnabled); - evaluator.accept(new ServerConfiguration("", "", observeFullEvaluationData, null, flags)); + FeatureFlaggingGateway.dispatch( + new ServerConfiguration("", "", observeFullEvaluationData, null, flags)); final EvaluationContext ctx = new MutableContext("target").setTargetingKey("user-1"); return evaluator.evaluate(Integer.class, "target", 23, ctx); @@ -529,7 +638,8 @@ private static ProviderEvaluation evaluateWithNumericRuleOnId( "num-rule", new Flag("num-rule", true, ValueType.INTEGER, emptyMap(), singletonList(allocation))); final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", observeFullEvaluationData, null, flags)); + FeatureFlaggingGateway.dispatch( + new ServerConfiguration("", "", observeFullEvaluationData, null, flags)); final EvaluationContext ctx = new MutableContext(targetingKey); return evaluator.evaluate(Integer.class, "num-rule", 23, ctx); @@ -644,7 +754,7 @@ public void testEvaluateSemverCondition( final Map flags = new HashMap<>(); flags.put("test-flag", semverFlag(operator, comparand)); final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", null, null, flags)); + FeatureFlaggingGateway.dispatch(new ServerConfiguration("", "", null, null, flags)); final ProviderEvaluation details = evaluator.evaluate(Boolean.class, "test-flag", false, semverContext(attribute)); @@ -663,7 +773,7 @@ public void testEvaluateSemverConditionMissingAttribute() { final Map flags = new HashMap<>(); flags.put("test-flag", semverFlag(ConditionOperator.SEMVER_EQ, "1.2.3")); final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(new ServerConfiguration("", "", null, null, flags)); + FeatureFlaggingGateway.dispatch(new ServerConfiguration("", "", null, null, flags)); final ProviderEvaluation details = evaluator.evaluate(Boolean.class, "test-flag", false, semverContext(null)); @@ -682,7 +792,7 @@ public void testEvaluateSemverConditionInvalidComparandReturnsParseError() { final ServerConfiguration config = new ServerConfiguration("", "", null, null, flags); config.invalidFlags = invalidFlags; final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(config); + FeatureFlaggingGateway.dispatch(config); final ProviderEvaluation details = evaluator.evaluate(Boolean.class, "invalid-semver", false, semverContext("1.2.3")); @@ -700,7 +810,7 @@ public void testEvaluateMalformedFlagReturnsParseError() { final ServerConfiguration config = new ServerConfiguration("", "", null, null, flags); config.invalidFlags = invalidFlags; final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(config); + FeatureFlaggingGateway.dispatch(config); final ProviderEvaluation details = evaluator.evaluate(Boolean.class, "malformed-flag", false, new MutableContext()); @@ -912,7 +1022,7 @@ public void testCanonicalFixturesArePresent() throws IOException { @ParameterizedTest(name = "{0}") public void testEvaluateCanonicalFixture(final FixtureCase testCase) throws IOException { final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); - evaluator.accept(loadCanonicalConfiguration()); + FeatureFlaggingGateway.dispatch(loadCanonicalConfiguration()); final Class targetType = targetType(testCase.variationType); final Object defaultValue = mapFixtureValue(targetType, testCase.defaultValue); diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/FeatureFlaggingGateway.java b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/FeatureFlaggingGateway.java index 2a823bd32ef..79be6073f3f 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/FeatureFlaggingGateway.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/main/java/datadog/trace/api/featureflag/FeatureFlaggingGateway.java @@ -10,6 +10,25 @@ public abstract class FeatureFlaggingGateway { + /** An immutable, process-wide view of the current configuration and its publication order. */ + public static final class ConfigSnapshot { + private final long version; + private final ServerConfiguration config; + + private ConfigSnapshot(final long version, final ServerConfiguration config) { + this.version = version; + this.config = config; + } + + public long getVersion() { + return version; + } + + public ServerConfiguration getConfig() { + return config; + } + } + public interface ConfigListener extends Consumer {} public interface ActivationListener { @@ -26,8 +45,8 @@ public interface SpanEnrichmentListener extends Consumer {} private static final List SPAN_ENRICHMENT_LISTENERS = new CopyOnWriteArrayList<>(); - private static final AtomicReference CURRENT_CONFIG = - new AtomicReference<>(); + private static final AtomicReference CURRENT_CONFIG = + new AtomicReference<>(new ConfigSnapshot(0, null)); /** * The active EVP flagevaluation writer. Registered by {@code FlagEvaluationWriterImpl.start()} @@ -44,9 +63,9 @@ private FeatureFlaggingGateway() {} public static void addConfigListener(final ConfigListener listener) { CONFIG_LISTENERS.add(listener); - final ServerConfiguration current = CURRENT_CONFIG.get(); - if (current != null) { - listener.accept(current); + final ConfigSnapshot current = CURRENT_CONFIG.get(); + if (current.getConfig() != null) { + listener.accept(current.getConfig()); } } @@ -55,10 +74,14 @@ public static void removeConfigListener(final ConfigListener listener) { } public static void dispatch(final ServerConfiguration config) { - CURRENT_CONFIG.set(config); + CURRENT_CONFIG.updateAndGet(current -> new ConfigSnapshot(current.getVersion() + 1, config)); CONFIG_LISTENERS.forEach(listener -> listener.accept(config)); } + public static ConfigSnapshot getConfigSnapshot() { + return CURRENT_CONFIG.get(); + } + public static void addActivationListener(final ActivationListener listener) { ACTIVATION_LISTENERS.add(listener); } diff --git a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/FeatureFlaggingGatewayTest.java b/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/FeatureFlaggingGatewayTest.java index 887a153f0a1..2f9c6874308 100644 --- a/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/FeatureFlaggingGatewayTest.java +++ b/products/feature-flagging/feature-flagging-bootstrap/src/test/java/datadog/trace/api/featureflag/FeatureFlaggingGatewayTest.java @@ -1,5 +1,7 @@ package datadog.trace.api.featureflag; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; @@ -78,6 +80,23 @@ void testAttachingAListenerAfterConfigured() { verifyNoMoreInteractions(configListener); } + @Test + void testDispatchPublishesVersionedConfigurationSnapshots() { + final long initialVersion = FeatureFlaggingGateway.getConfigSnapshot().getVersion(); + + FeatureFlaggingGateway.dispatch(firstConfiguration); + final FeatureFlaggingGateway.ConfigSnapshot firstSnapshot = + FeatureFlaggingGateway.getConfigSnapshot(); + FeatureFlaggingGateway.dispatch(secondConfiguration); + final FeatureFlaggingGateway.ConfigSnapshot secondSnapshot = + FeatureFlaggingGateway.getConfigSnapshot(); + + assertEquals(initialVersion + 1, firstSnapshot.getVersion()); + assertSame(firstConfiguration, firstSnapshot.getConfig()); + assertEquals(firstSnapshot.getVersion() + 1, secondSnapshot.getVersion()); + assertSame(secondConfiguration, secondSnapshot.getConfig()); + } + @Test void testAttachingAnExposureListener() { FeatureFlaggingGateway.addExposureListener(exposureListener); From b0492ecdcdedd415687a897cbb8fae8238bb018d Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 3 Sep 2026 11:09:17 +0000 Subject: [PATCH 4/6] Address OpenFeature provider review feedback Cover telemetry suppression through reflective provider construction, snapshot provider options, and clarify agent compatibility failures. Environment: Datadog workspace --- .../feature-flagging-api/README.md | 3 + .../trace/api/openfeature/DDEvaluator.java | 7 +- .../trace/api/openfeature/Provider.java | 22 +++-- .../api/openfeature/DDEvaluatorTest.java | 18 +++- .../trace/api/openfeature/ProviderTest.java | 85 ++++++++++++++++++- 5 files changed, 123 insertions(+), 12 deletions(-) diff --git a/products/feature-flagging/feature-flagging-api/README.md b/products/feature-flagging/feature-flagging-api/README.md index ca01d2e6a4d..9119c9604be 100644 --- a/products/feature-flagging/feature-flagging-api/README.md +++ b/products/feature-flagging/feature-flagging-api/README.md @@ -16,6 +16,9 @@ Published as `com.datadoghq:dd-openfeature` on Maven Central. The OpenFeature SDK (`dev.openfeature:sdk`) is included as a transitive dependency. +`dd-openfeature` 1.66.0 and later requires `dd-java-agent` 1.66.0 or later. Keep the +Java agent at the same or a newer version than the provider artifact. + ### Evaluation metrics (optional) To enable evaluation metrics (`feature_flag.evaluations` counter), enable the Datadog Java agent's 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 88a4cfd3bf9..e562f6c6842 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 @@ -115,7 +115,7 @@ class DDEvaluator implements Evaluator, FeatureFlaggingGateway.ConfigListener { private final CountDownLatch initializationLatch = new CountDownLatch(1); private long lastConfigVersion; - public DDEvaluator(final Runnable configCallback) { + DDEvaluator(final Runnable configCallback) { this(configCallback, true); } @@ -566,11 +566,12 @@ private ProviderEvaluation resolveVariant( .addLong("__dd_eval_timestamp_ms", evalTimestampMs) .addBoolean(METADATA_OBSERVE_FULL_EVALUATION_DATA, observeFullEvaluationData); // Surface the UFC split's serial id and the allocation's doLog flag for APM span enrichment — - // only when span enrichment is on, so a provider without enrichment pays nothing extra. + // only when telemetry and span enrichment are on, so a provider without enrichment pays + // nothing extra. // __dd_split_serial_id is omitted when the split carries no serial id; __dd_do_log is always // present (when enrichment is on) so the span-enrichment hook can decide whether to record the // subject. - if (SPAN_ENRICHMENT_ENABLED) { + if (telemetryEnabled && SPAN_ENRICHMENT_ENABLED) { if (split.serialId != null) { metadataBuilder.addInteger(METADATA_SPLIT_SERIAL_ID, split.serialId); } 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 adc9ad949c6..26c67eaa81f 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 @@ -1,5 +1,6 @@ package datadog.trace.api.openfeature; +import static java.util.Objects.requireNonNull; import static java.util.concurrent.TimeUnit.SECONDS; import de.thetaphi.forbiddenapis.SuppressForbidden; @@ -31,7 +32,8 @@ public class Provider extends EventProvider implements Metadata { private static final String EVALUATOR_IMPL = "datadog.trace.api.openfeature.DDEvaluator"; private static final long DEFAULT_INIT_TIMEOUT = 30; private volatile Evaluator evaluator; - private final Options options; + private final long initTimeout; + private final TimeUnit initTimeoutUnit; private final boolean telemetryEnabled; private final AtomicReference initializationState = new AtomicReference<>(InitializationState.NOT_STARTED); @@ -56,14 +58,17 @@ public Provider(final Options options) { } /** - * @param spanEnrichmentEnabledOverride when non-null, forces the span-enrichment gate (test - * seam); when null, the gate is read via {@link SpanEnrichmentGate}. + * @param spanEnrichmentEnabledOverride when non-null, replaces the {@link SpanEnrichmentGate} + * reading (test seam); when null, the gate is read via {@link SpanEnrichmentGate}. Either way + * span enrichment stays off when {@code options.isTelemetryEnabled()} is false. */ Provider( final Options options, final Evaluator evaluator, final Boolean spanEnrichmentEnabledOverride) { - this.options = options; + requireNonNull(options, "options"); + this.initTimeout = options.getTimeout(); + this.initTimeoutUnit = options.getUnit(); this.telemetryEnabled = options.isTelemetryEnabled(); this.evaluator = evaluator; @@ -127,7 +132,7 @@ public void initialize(final EvaluationContext context) throws Exception { initializationState.set(InitializationState.INITIALIZING); try { evaluator = buildEvaluator(); - if (!evaluator.initialize(options.getTimeout(), options.getUnit(), context)) { + if (!evaluator.initialize(initTimeout, initTimeoutUnit, context)) { if (markInitialConfigReceivedReady()) { return; } @@ -143,6 +148,13 @@ public void initialize(final EvaluationContext context) throws Exception { } catch (final OpenFeatureError e) { markInitializationError(); throw e; + } catch (final LinkageError e) { + markInitializationError(); + throw new FatalError( + "Failed to initialize provider: a required Datadog agent class or method is missing." + + " The Datadog Java agent is likely absent, or older than this dd-openfeature" + + " release.", + e); } catch (final Throwable e) { markInitializationError(); throw new FatalError("Failed to initialize provider, is the tracer configured?", e); 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 460b4fbf0d0..fe357bdfbbd 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 @@ -425,6 +425,14 @@ public void telemetryDisabledSuppressesExposures() { } } + @Test + public void telemetryDisabledOmitsSpanEnrichmentMetadata() { + final ProviderEvaluation details = evaluateMatchingFlag(false, true, false, 17); + + assertNull(details.getFlagMetadata().getInteger(DDEvaluator.METADATA_SPLIT_SERIAL_ID)); + assertNull(details.getFlagMetadata().getBoolean(DDEvaluator.METADATA_DO_LOG)); + } + // -- DISABLED path: flag.enabled=false -- @Test @@ -538,9 +546,17 @@ private static ProviderEvaluation evaluateMatchingFlag( final boolean observeFullEvaluationData, final boolean doLog, final boolean telemetryEnabled) { + return evaluateMatchingFlag(observeFullEvaluationData, doLog, telemetryEnabled, null); + } + + private static ProviderEvaluation evaluateMatchingFlag( + final boolean observeFullEvaluationData, + final boolean doLog, + final boolean telemetryEnabled, + final Integer serialId) { final Map variations = new HashMap<>(); variations.put("on", new Variant("on", 1)); - final Split split = new Split(emptyList(), "on", emptyMap(), null); + final Split split = new Split(emptyList(), "on", emptyMap(), serialId); final Allocation allocation = new Allocation("alloc-1", null, null, null, singletonList(split), doLog); return evaluateFlag( 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 fb669e45ff0..d83920f9787 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 @@ -1,11 +1,15 @@ package datadog.trace.api.openfeature; import static datadog.trace.api.openfeature.Provider.METADATA; +import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonList; import static java.util.concurrent.TimeUnit.MILLISECONDS; import static java.util.concurrent.TimeUnit.SECONDS; import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -18,9 +22,15 @@ import static org.mockito.Mockito.when; import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import datadog.trace.api.featureflag.exposure.ExposureEvent; import datadog.trace.api.featureflag.flagevaluation.FlagEvalEvent; import datadog.trace.api.featureflag.flagevaluation.FlagEvaluationWriter; +import datadog.trace.api.featureflag.ufc.v1.Allocation; +import datadog.trace.api.featureflag.ufc.v1.Flag; import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; +import datadog.trace.api.featureflag.ufc.v1.Split; +import datadog.trace.api.featureflag.ufc.v1.ValueType; +import datadog.trace.api.featureflag.ufc.v1.Variant; import datadog.trace.api.openfeature.Provider.Options; import dev.openfeature.sdk.Client; import dev.openfeature.sdk.ErrorCode; @@ -39,6 +49,8 @@ import dev.openfeature.sdk.exceptions.FatalError; import dev.openfeature.sdk.exceptions.ProviderNotReadyError; import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -319,6 +331,24 @@ protected Class loadEvaluatorClass() throws ClassNotFoundException { })); } + @Test + public void testMissingAgentMethodReportsCompatibilityError() throws Exception { + final Evaluator evaluator = mock(Evaluator.class); + when(evaluator.initialize(eq(30L), eq(SECONDS), any())) + .thenThrow(new NoSuchMethodError("FeatureFlaggingGateway.getConfigSnapshot")); + final Provider provider = new Provider(new Options(), evaluator); + + final FatalError error = assertThrows(FatalError.class, () -> provider.initialize(null)); + + assertThat( + error.getMessage(), + equalTo( + "Failed to initialize provider: a required Datadog agent class or method is missing." + + " The Datadog Java agent is likely absent, or older than this dd-openfeature" + + " release.")); + assertTrue(error.getCause() instanceof NoSuchMethodError); + } + @Test public void testGetProviderHooksReturnsTelemetryHooks() { final Provider provider = @@ -352,6 +382,39 @@ public void testTelemetryDisabledProviderHasNoHooks() { assertNull(provider.spanEnrichmentHook()); } + @Test + public void testTelemetryDisabledDomainSuppressesExposuresThroughReflectiveEvaluator() + throws Exception { + final Map variations = new HashMap<>(); + variations.put("on", new Variant("on", true)); + final Split split = new Split(emptyList(), "on", emptyMap(), null); + final Allocation allocation = + new Allocation("alloc-1", null, null, null, singletonList(split), Boolean.TRUE); + final Map flags = new HashMap<>(); + flags.put( + "logged-flag", + new Flag("logged-flag", true, ValueType.BOOLEAN, variations, singletonList(allocation))); + FeatureFlaggingGateway.dispatch(new ServerConfiguration("", "", false, null, flags)); + + final AtomicReference exposure = new AtomicReference<>(); + final FeatureFlaggingGateway.ExposureListener listener = exposure::set; + FeatureFlaggingGateway.addExposureListener(listener); + try { + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + api.setProviderAndWait("live", new Provider()); + api.setProviderAndWait("peek", new Provider(new Options().telemetryEnabled(false))); + final MutableContext context = new MutableContext("user-1"); + + assertTrue(api.getClient("peek").getBooleanValue("logged-flag", false, context)); + assertNull(exposure.get(), "the peek domain must not record an exposure"); + + assertTrue(api.getClient("live").getBooleanValue("logged-flag", false, context)); + assertNotNull(exposure.get(), "the live domain must record an exposure"); + } finally { + FeatureFlaggingGateway.removeExposureListener(listener); + } + } + @Test public void testOptionsRetainDefaultTimeoutWhenOnlyTelemetryIsConfigured() { final Options options = new Options().telemetryEnabled(false); @@ -361,6 +424,21 @@ public void testOptionsRetainDefaultTimeoutWhenOnlyTelemetryIsConfigured() { assertFalse(options.isTelemetryEnabled()); } + @Test + public void testProviderSnapshotsOptionsAtConstruction() throws Exception { + final Options options = new Options().initTimeout(10, MILLISECONDS); + final Evaluator evaluator = mock(Evaluator.class); + when(evaluator.initialize(eq(10L), eq(MILLISECONDS), any())).thenReturn(true); + when(evaluator.hasConfiguration()).thenReturn(true); + final Provider provider = new Provider(options, evaluator, Boolean.FALSE); + + options.initTimeout(20, SECONDS).telemetryEnabled(false); + provider.initialize(null); + + verify(evaluator).initialize(eq(10L), eq(MILLISECONDS), any()); + assertHasHook(provider, FlagEvalLoggingHook.class); + } + @Test public void testClientEvaluationRoutesThroughFlagEvalLoggingHook() throws Exception { FeatureFlaggingGateway.dispatch(mock(ServerConfiguration.class)); @@ -434,9 +512,10 @@ private static void assertHasFlagEvalMetricsHook(final Provider provider) { private static void assertHasHook( final Provider provider, final Class hookClass) { - assertTrue( - provider.getProviderHooks().stream().anyMatch(hookClass::isInstance), - hookClass.getSimpleName() + " should be registered"); + assertThat( + hookClass.getSimpleName() + " should be registered exactly once", + provider.getProviderHooks().stream().filter(hookClass::isInstance).count(), + equalTo(1L)); } public interface EvaluateMethod { From a7b19afdf9a0ddc9771655cb0ae0c1b1a369f875 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 3 Sep 2026 11:14:18 +0000 Subject: [PATCH 5/6] Clarify dd-openfeature agent compatibility Document the 1.66.0 agent API floor without implying lockstep provider and agent versions. Environment: Datadog workspace --- products/feature-flagging/feature-flagging-api/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/products/feature-flagging/feature-flagging-api/README.md b/products/feature-flagging/feature-flagging-api/README.md index 9119c9604be..e41d40d8525 100644 --- a/products/feature-flagging/feature-flagging-api/README.md +++ b/products/feature-flagging/feature-flagging-api/README.md @@ -16,8 +16,8 @@ Published as `com.datadoghq:dd-openfeature` on Maven Central. The OpenFeature SDK (`dev.openfeature:sdk`) is included as a transitive dependency. -`dd-openfeature` 1.66.0 and later requires `dd-java-agent` 1.66.0 or later. Keep the -Java agent at the same or a newer version than the provider artifact. +`dd-openfeature` 1.66.0 and later requires `dd-java-agent` 1.66.0 or later because it uses +the process-wide Feature Flagging configuration snapshot API. ### Evaluation metrics (optional) From ffdd4ff1e84a1b8464cc0cf591cb91db50348bcb Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Thu, 3 Sep 2026 16:24:28 +0000 Subject: [PATCH 6/6] Publish dd-openfeature PR snapshots Expose the provider beside the agent in the existing public S3 snapshot job. Environment: Datadog workspace --- .gitlab-ci.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index df0fca12659..ec1b29bb8ff 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -350,7 +350,7 @@ build: script: - if [ $CI_PIPELINE_SOURCE == "schedule" ] ; then ./gradlew resolveAndLockAll --write-locks $GRADLE_ARGS; fi - ./gradlew --version - - ./gradlew clean :dd-java-agent:shadowJar :dd-java-agent:check :dd-trace-api:jar :dd-trace-ot:shadowJar -PskipTests -x spotlessCheck $GRADLE_ARGS + - ./gradlew clean :dd-java-agent:shadowJar :dd-java-agent:check :dd-trace-api:jar :dd-trace-ot:shadowJar :products:feature-flagging:feature-flagging-api:jar -PskipTests -x spotlessCheck $GRADLE_ARGS - echo UPSTREAM_TRACER_VERSION=$(java -jar workspace/dd-java-agent/build/libs/*.jar) >> upstream.env - echo "BUILD_JOB_NAME=$CI_JOB_NAME" >> build.env - echo "BUILD_JOB_ID=$CI_JOB_ID" >> build.env @@ -360,6 +360,7 @@ build: - 'workspace/dd-java-agent/build/libs/*.jar' - 'workspace/dd-trace-api/build/libs/*.jar' - 'workspace/dd-trace-ot/build/libs/*.jar' + - 'workspace/products/feature-flagging/feature-flagging-api/build/libs/*.jar' - 'upstream.env' - '.gradle/daemon/*/*.out.log' reports: @@ -425,9 +426,11 @@ publish-artifacts-to-s3: - aws s3 cp workspace/dd-java-agent/build/libs/dd-java-agent-${VERSION}.jar s3://dd-trace-java-builds/${CI_COMMIT_REF_NAME}/dd-java-agent.jar - aws s3 cp workspace/dd-trace-api/build/libs/dd-trace-api-${VERSION}.jar s3://dd-trace-java-builds/${CI_COMMIT_REF_NAME}/dd-trace-api.jar - aws s3 cp workspace/dd-trace-ot/build/libs/dd-trace-ot-${VERSION}.jar s3://dd-trace-java-builds/${CI_COMMIT_REF_NAME}/dd-trace-ot.jar + - aws s3 cp workspace/products/feature-flagging/feature-flagging-api/build/libs/dd-openfeature-${VERSION}.jar s3://dd-trace-java-builds/${CI_COMMIT_REF_NAME}/dd-openfeature.jar - aws s3 cp workspace/dd-java-agent/build/libs/dd-java-agent-${VERSION}.jar s3://dd-trace-java-builds/${CI_PIPELINE_ID}/dd-java-agent.jar - aws s3 cp workspace/dd-trace-api/build/libs/dd-trace-api-${VERSION}.jar s3://dd-trace-java-builds/${CI_PIPELINE_ID}/dd-trace-api.jar - aws s3 cp workspace/dd-trace-ot/build/libs/dd-trace-ot-${VERSION}.jar s3://dd-trace-java-builds/${CI_PIPELINE_ID}/dd-trace-ot.jar + - aws s3 cp workspace/products/feature-flagging/feature-flagging-api/build/libs/dd-openfeature-${VERSION}.jar s3://dd-trace-java-builds/${CI_PIPELINE_ID}/dd-openfeature.jar - | cat << EOF > links.json { @@ -437,6 +440,12 @@ publish-artifacts-to-s3: "label": "Public Link to dd-java-agent.jar", "url": "https://s3.us-east-1.amazonaws.com/dd-trace-java-builds/${CI_PIPELINE_ID}/dd-java-agent.jar" } + }, + { + "external_link": { + "label": "Public Link to dd-openfeature.jar", + "url": "https://s3.us-east-1.amazonaws.com/dd-trace-java-builds/${CI_PIPELINE_ID}/dd-openfeature.jar" + } } ] }