From 6148416665bbe11a32e74f6da0ad125ac5a88e97 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 11 Aug 2026 16:27:48 -0400 Subject: [PATCH 1/3] Prototype SSI OpenFeature provider injection --- dd-java-agent/agent-bootstrap/build.gradle | 1 + .../java/datadog/trace/bootstrap/Agent.java | 3 + .../trace/agent/tooling/AgentInstaller.java | 4 ++ .../agent/tooling/InstrumenterModule.java | 14 ++++ .../openfeature/openfeature-1.20/build.gradle | 20 ++++++ .../OpenFeatureAPIInstrumentation.java | 64 +++++++++++++++++++ .../OpenFeatureProviderInstaller.java | 31 +++++++++ .../OpenFeatureProviderInjectionTest.groovy | 60 +++++++++++++++++ .../datadog/trace/api/ConfigTest.groovy | 10 +-- metadata/supported-configurations.json | 8 +++ .../featureflag/FeatureFlaggingSystem.java | 34 +--------- .../FeatureFlaggingSystemTest.java | 34 +--------- .../featureflag/FeatureFlaggingGateway.java | 11 ++++ .../FeatureFlaggingGatewayTest.java | 12 ++++ .../config/FeatureFlaggingConfig.java | 2 +- .../config/FeatureFlaggingConfigTest.java | 5 +- .../AgentlessConfigurationSource.java | 6 +- settings.gradle.kts | 1 + 18 files changed, 243 insertions(+), 77 deletions(-) create mode 100644 dd-java-agent/instrumentation/openfeature/openfeature-1.20/build.gradle create mode 100644 dd-java-agent/instrumentation/openfeature/openfeature-1.20/src/main/java/datadog/trace/instrumentation/openfeature/OpenFeatureAPIInstrumentation.java create mode 100644 dd-java-agent/instrumentation/openfeature/openfeature-1.20/src/main/java/datadog/trace/instrumentation/openfeature/OpenFeatureProviderInstaller.java create mode 100644 dd-java-agent/instrumentation/openfeature/openfeature-1.20/src/test/groovy/OpenFeatureProviderInjectionTest.groovy diff --git a/dd-java-agent/agent-bootstrap/build.gradle b/dd-java-agent/agent-bootstrap/build.gradle index 8bdf1a944f3..10be27679f6 100644 --- a/dd-java-agent/agent-bootstrap/build.gradle +++ b/dd-java-agent/agent-bootstrap/build.gradle @@ -23,6 +23,7 @@ dependencies { api project(':dd-java-agent:agent-debugger:debugger-bootstrap') api project(':components:environment') api project(':components:json') + api project(':products:feature-flagging:feature-flagging-bootstrap') api project(':products:feature-flagging:feature-flagging-config') api project(':products:metrics:metrics-agent') api libs.instrument.java diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/Agent.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/Agent.java index a611a422d7b..bae635b59fc 100644 --- a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/Agent.java +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/Agent.java @@ -44,6 +44,7 @@ import datadog.trace.api.config.TraceInstrumentationConfig; import datadog.trace.api.config.TracerConfig; import datadog.trace.api.config.UsmConfig; +import datadog.trace.api.featureflag.FeatureFlaggingGateway; import datadog.trace.api.featureflag.config.FeatureFlaggingConfig; import datadog.trace.api.gateway.RequestContextSlot; import datadog.trace.api.gateway.SubscriptionService; @@ -285,6 +286,7 @@ public static void start( appLogsCollectionEnabled = isFeatureEnabled(AgentFeature.APP_LOGS_COLLECTION); llmObsEnabled = isFeatureEnabled(AgentFeature.LLMOBS); featureFlaggingEnabled = isFeatureFlaggingEnabled(); + FeatureFlaggingGateway.setProviderInjectionEnabled(featureFlaggingEnabled); // setup writers when llmobs is enabled to accomodate apm and llmobs if (llmObsEnabled) { @@ -531,6 +533,7 @@ public static void shutdown(final boolean sync) { stopFlarePoller(); } if (featureFlaggingEnabled) { + FeatureFlaggingGateway.setProviderInjectionEnabled(false); shutdownFeatureFlagging(AGENT_CLASSLOADER); } diff --git a/dd-java-agent/agent-installer/src/main/java/datadog/trace/agent/tooling/AgentInstaller.java b/dd-java-agent/agent-installer/src/main/java/datadog/trace/agent/tooling/AgentInstaller.java index 3a8c7065362..ef4a00c2618 100644 --- a/dd-java-agent/agent-installer/src/main/java/datadog/trace/agent/tooling/AgentInstaller.java +++ b/dd-java-agent/agent-installer/src/main/java/datadog/trace/agent/tooling/AgentInstaller.java @@ -16,6 +16,7 @@ import datadog.trace.api.InstrumenterConfig; import datadog.trace.api.Platform; import datadog.trace.api.ProductActivation; +import datadog.trace.api.featureflag.FeatureFlaggingGateway; import datadog.trace.api.telemetry.IntegrationsCollector; import datadog.trace.bootstrap.FieldBackedContextAccessor; import datadog.trace.bootstrap.instrumentation.java.concurrent.ExcludeFilter; @@ -328,6 +329,9 @@ public static Set getEnabledSystems() { if (cfg.isCiVisibilityEnabled()) { enabledSystems.add(InstrumenterModule.TargetSystem.CIVISIBILITY); } + if (FeatureFlaggingGateway.isProviderInjectionEnabled()) { + enabledSystems.add(InstrumenterModule.TargetSystem.FEATURE_FLAGS); + } if (cfg.isUsmEnabled()) { enabledSystems.add(InstrumenterModule.TargetSystem.USM); } diff --git a/dd-java-agent/agent-tooling/src/main/java/datadog/trace/agent/tooling/InstrumenterModule.java b/dd-java-agent/agent-tooling/src/main/java/datadog/trace/agent/tooling/InstrumenterModule.java index d2abbc265e5..1e10304b73a 100644 --- a/dd-java-agent/agent-tooling/src/main/java/datadog/trace/agent/tooling/InstrumenterModule.java +++ b/dd-java-agent/agent-tooling/src/main/java/datadog/trace/agent/tooling/InstrumenterModule.java @@ -40,6 +40,7 @@ public abstract class InstrumenterModule implements Instrumenter { *
  • {@link TargetSystem#APPSEC appsec} *
  • {@link TargetSystem#IAST iast} *
  • {@link TargetSystem#CIVISIBILITY ci-visibility} + *
  • {@link TargetSystem#FEATURE_FLAGS feature-flags} *
  • {@link TargetSystem#USM usm} *
  • {@link TargetSystem#CONTEXT_TRACKING context-tracking} *
  • {@link TargetSystem#RASP rasp} @@ -51,6 +52,7 @@ public enum TargetSystem { APPSEC, IAST, CIVISIBILITY, + FEATURE_FLAGS, USM, LLMOBS, CONTEXT_TRACKING, @@ -249,6 +251,18 @@ public final boolean isApplicable(Set enabledSystems) { } } + /** Parent class for all Feature Flags related instrumentations. */ + public abstract static class FeatureFlags extends InstrumenterModule { + public FeatureFlags(String instrumentationName, String... additionalNames) { + super(instrumentationName, additionalNames); + } + + @Override + public final boolean isApplicable(Set enabledSystems) { + return enabledSystems.contains(TargetSystem.FEATURE_FLAGS); + } + } + /** Parent class for all profiling related instrumentations */ public abstract static class Profiling extends InstrumenterModule { public Profiling(String instrumentationName, String... additionalNames) { diff --git a/dd-java-agent/instrumentation/openfeature/openfeature-1.20/build.gradle b/dd-java-agent/instrumentation/openfeature/openfeature-1.20/build.gradle new file mode 100644 index 00000000000..afbc940ad2c --- /dev/null +++ b/dd-java-agent/instrumentation/openfeature/openfeature-1.20/build.gradle @@ -0,0 +1,20 @@ +muzzle { + pass { + group = 'dev.openfeature' + module = 'sdk' + versions = '[1.20.1,1.21)' + } +} + +apply from: "$rootDir/gradle/java.gradle" + +dependencies { + compileOnly group: 'dev.openfeature', name: 'sdk', version: '1.20.1' + + implementation(project(':products:feature-flagging:feature-flagging-api')) { + transitive = false + } + + testImplementation project(':products:feature-flagging:feature-flagging-bootstrap') + testImplementation group: 'dev.openfeature', name: 'sdk', version: '1.20.1' +} diff --git a/dd-java-agent/instrumentation/openfeature/openfeature-1.20/src/main/java/datadog/trace/instrumentation/openfeature/OpenFeatureAPIInstrumentation.java b/dd-java-agent/instrumentation/openfeature/openfeature-1.20/src/main/java/datadog/trace/instrumentation/openfeature/OpenFeatureAPIInstrumentation.java new file mode 100644 index 00000000000..62b17f5f6d9 --- /dev/null +++ b/dd-java-agent/instrumentation/openfeature/openfeature-1.20/src/main/java/datadog/trace/instrumentation/openfeature/OpenFeatureAPIInstrumentation.java @@ -0,0 +1,64 @@ +package datadog.trace.instrumentation.openfeature; + +import static net.bytebuddy.matcher.ElementMatchers.isMethod; +import static net.bytebuddy.matcher.ElementMatchers.isStatic; +import static net.bytebuddy.matcher.ElementMatchers.named; +import static net.bytebuddy.matcher.ElementMatchers.returns; +import static net.bytebuddy.matcher.ElementMatchers.takesNoArguments; + +import com.google.auto.service.AutoService; +import datadog.trace.agent.tooling.Instrumenter; +import datadog.trace.agent.tooling.InstrumenterModule; +import dev.openfeature.sdk.OpenFeatureAPI; +import net.bytebuddy.asm.Advice; + +@AutoService(InstrumenterModule.class) +public class OpenFeatureAPIInstrumentation extends InstrumenterModule.FeatureFlags + implements Instrumenter.ForSingleType, Instrumenter.HasMethodAdvice { + + public OpenFeatureAPIInstrumentation() { + super("openfeature"); + } + + @Override + public String instrumentedType() { + return "dev.openfeature.sdk.OpenFeatureAPI"; + } + + @Override + public String[] helperClassNames() { + return new String[] { + "datadog.trace.api.openfeature.Evaluator", + "datadog.trace.api.openfeature.DDEvaluator$1", + "datadog.trace.api.openfeature.DDEvaluator$FlattenEntry", + "datadog.trace.api.openfeature.DDEvaluator$NumberComparator", + "datadog.trace.api.openfeature.DDEvaluator", + "datadog.trace.api.openfeature.FlagEvalMetrics", + "datadog.trace.api.openfeature.FlagEvalHook", + "datadog.trace.api.openfeature.SpanEnrichmentGate", + "datadog.trace.api.openfeature.SpanEnrichmentHook", + "datadog.trace.api.openfeature.Provider$InitializationState", + "datadog.trace.api.openfeature.Provider$Options", + "datadog.trace.api.openfeature.Provider", + packageName + ".OpenFeatureProviderInstaller", + }; + } + + @Override + public void methodAdvice(final MethodTransformer transformer) { + transformer.applyAdvice( + isMethod() + .and(isStatic()) + .and(named("getInstance")) + .and(takesNoArguments()) + .and(returns(named("dev.openfeature.sdk.OpenFeatureAPI"))), + OpenFeatureAPIInstrumentation.class.getName() + "$GetInstanceAdvice"); + } + + public static class GetInstanceAdvice { + @Advice.OnMethodExit(suppress = Throwable.class) + public static void installProvider(@Advice.Return final OpenFeatureAPI api) { + OpenFeatureProviderInstaller.install(api); + } + } +} diff --git a/dd-java-agent/instrumentation/openfeature/openfeature-1.20/src/main/java/datadog/trace/instrumentation/openfeature/OpenFeatureProviderInstaller.java b/dd-java-agent/instrumentation/openfeature/openfeature-1.20/src/main/java/datadog/trace/instrumentation/openfeature/OpenFeatureProviderInstaller.java new file mode 100644 index 00000000000..e0da93b6d4e --- /dev/null +++ b/dd-java-agent/instrumentation/openfeature/openfeature-1.20/src/main/java/datadog/trace/instrumentation/openfeature/OpenFeatureProviderInstaller.java @@ -0,0 +1,31 @@ +package datadog.trace.instrumentation.openfeature; + +import datadog.trace.api.featureflag.FeatureFlaggingGateway; +import datadog.trace.api.openfeature.Provider; +import dev.openfeature.sdk.FeatureProvider; +import dev.openfeature.sdk.NoOpProvider; +import dev.openfeature.sdk.OpenFeatureAPI; + +public final class OpenFeatureProviderInstaller { + + private static boolean installationComplete; + + private OpenFeatureProviderInstaller() {} + + public static synchronized void install(final OpenFeatureAPI api) { + if (installationComplete + || api == null + || !FeatureFlaggingGateway.isProviderInjectionEnabled()) { + return; + } + + final FeatureProvider currentProvider = api.getProvider(); + if (currentProvider == null || currentProvider.getClass() != NoOpProvider.class) { + installationComplete = true; + return; + } + + api.setProvider(new Provider()); + installationComplete = true; + } +} diff --git a/dd-java-agent/instrumentation/openfeature/openfeature-1.20/src/test/groovy/OpenFeatureProviderInjectionTest.groovy b/dd-java-agent/instrumentation/openfeature/openfeature-1.20/src/test/groovy/OpenFeatureProviderInjectionTest.groovy new file mode 100644 index 00000000000..31ef1a4bc6a --- /dev/null +++ b/dd-java-agent/instrumentation/openfeature/openfeature-1.20/src/test/groovy/OpenFeatureProviderInjectionTest.groovy @@ -0,0 +1,60 @@ +import datadog.trace.agent.test.InstrumentationSpecification +import datadog.trace.api.featureflag.FeatureFlaggingGateway +import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration +import dev.openfeature.sdk.Metadata +import dev.openfeature.sdk.NoOpProvider +import dev.openfeature.sdk.OpenFeatureAPI + +import static java.util.Collections.emptyMap + +class OpenFeatureProviderInjectionTest extends InstrumentationSpecification { + + @Override + protected void configurePreAgent() { + super.configurePreAgent() + injectSysConfig("trace.enabled", "false") + injectSysConfig("trace.openfeature.enabled", "true") + FeatureFlaggingGateway.setProviderInjectionEnabled(true) + } + + def cleanup() { + FeatureFlaggingGateway.setProviderInjectionEnabled(false) + FeatureFlaggingGateway.dispatch((ServerConfiguration) null) + OpenFeatureAPI.getInstance().shutdown() + } + + def "injects once only after explicit activation"() { + given: "the instrumentation loaded for Feature Flags but provider installation is disabled" + FeatureFlaggingGateway.setProviderInjectionEnabled(false) + + when: "OpenFeature loads without explicit Feature Flags activation" + def api = OpenFeatureAPI.getInstance() + + then: + TRANSFORMED_CLASSES_NAMES.contains("dev.openfeature.sdk.OpenFeatureAPI") + api.provider.class == NoOpProvider + + when: "the Java agent enables provider injection" + FeatureFlaggingGateway.dispatch(new ServerConfiguration(null, null, null, emptyMap())) + FeatureFlaggingGateway.setProviderInjectionEnabled(true) + api = OpenFeatureAPI.getInstance() + + then: + api.provider.metadata.name == "datadog-openfeature-provider" + + when: "application code selects another provider" + def customerProvider = new CustomerProvider() + api.setProvider(customerProvider) + OpenFeatureAPI.getInstance() + + then: + api.provider.is(customerProvider) + } + + private static final class CustomerProvider extends NoOpProvider { + @Override + Metadata getMetadata() { + return { "customer-provider" } + } + } +} diff --git a/internal-api/src/test/groovy/datadog/trace/api/ConfigTest.groovy b/internal-api/src/test/groovy/datadog/trace/api/ConfigTest.groovy index ef95d5e902c..ac66d7dbf4a 100644 --- a/internal-api/src/test/groovy/datadog/trace/api/ConfigTest.groovy +++ b/internal-api/src/test/groovy/datadog/trace/api/ConfigTest.groovy @@ -3520,9 +3520,9 @@ class ConfigTest extends DDSpecification { where: value | expected - null | "agentless" - "" | "agentless" - " " | "agentless" + null | null + "" | null + " " | null " ReMoTe_ConFiG " | "remote_config" "not-a-real-source" | "not-a-real-source" " OFFLINE " | "offline" @@ -3550,8 +3550,8 @@ class ConfigTest extends DDSpecification { where: providerEnabled | source | legacyProviderEnabled | expectedEnabled | expectedSource - null | null | null | true | "agentless" - true | null | null | true | "agentless" + null | null | null | false | null + true | null | null | false | null null | null | true | true | "remote_config" null | null | false | false | null null | "agentless" | true | true | "agentless" diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json index aaad660aae9..8973dddca77 100644 --- a/metadata/supported-configurations.json +++ b/metadata/supported-configurations.json @@ -8553,6 +8553,14 @@ "aliases": ["DD_TRACE_INTEGRATION_OPENAI_JAVA_ENABLED", "DD_INTEGRATION_OPENAI_JAVA_ENABLED"] } ], + "DD_TRACE_OPENFEATURE_ENABLED": [ + { + "version": "A", + "type": "boolean", + "default": "true", + "aliases": ["DD_TRACE_INTEGRATION_OPENFEATURE_ENABLED", "DD_INTEGRATION_OPENFEATURE_ENABLED"] + } + ], "DD_TRACE_OPENSEARCH_ANALYTICS_ENABLED": [ { "version": "A", diff --git a/products/feature-flagging/feature-flagging-agent/src/main/java/com/datadog/featureflag/FeatureFlaggingSystem.java b/products/feature-flagging/feature-flagging-agent/src/main/java/com/datadog/featureflag/FeatureFlaggingSystem.java index 91b32ee1d64..acf70dc2ce0 100644 --- a/products/feature-flagging/feature-flagging-agent/src/main/java/com/datadog/featureflag/FeatureFlaggingSystem.java +++ b/products/feature-flagging/feature-flagging-agent/src/main/java/com/datadog/featureflag/FeatureFlaggingSystem.java @@ -5,7 +5,6 @@ import datadog.communication.ddagent.SharedCommunicationObjects; import datadog.trace.api.Config; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -17,7 +16,6 @@ public class FeatureFlaggingSystem { private static volatile ConfigurationSourceService CONFIG_SERVICE; private static volatile ExposureWriter EXPOSURE_WRITER; private static volatile SpanEnrichmentWriter SPAN_ENRICHMENT_WRITER; - private static volatile FeatureFlaggingGateway.ActivationListener ACTIVATION_LISTENER; private static volatile boolean STARTED; private FeatureFlaggingSystem() {} @@ -40,31 +38,6 @@ public static synchronized void start(final SharedCommunicationObjects sco) { return; } - if (CONFIGURATION_SOURCE_AGENTLESS.equals(config.getFeatureFlaggingConfigurationSource())) { - final FeatureFlaggingGateway.ActivationListener activationListener = - () -> activateAgentless(sco, config); - ACTIVATION_LISTENER = activationListener; - FeatureFlaggingGateway.addActivationListener(activationListener); - LOGGER.debug("Feature Flagging system awaiting application provider activation"); - return; - } - - try { - initializeSystem(sco, config); - } catch (final RuntimeException | Error e) { - STARTED = false; - throw e; - } - } - - private static synchronized void activateAgentless( - final SharedCommunicationObjects sco, final Config config) { - final FeatureFlaggingGateway.ActivationListener activationListener = ACTIVATION_LISTENER; - if (!STARTED || activationListener == null) { - return; - } - ACTIVATION_LISTENER = null; - FeatureFlaggingGateway.removeActivationListener(activationListener); try { initializeSystem(sco, config); } catch (final RuntimeException | Error e) { @@ -134,18 +107,13 @@ static ConfigurationSourceService createConfigurationSourceService( justification = "Agent-internal class; Class object does not escape to app code and lock only guards the subsystem lifecycle.") public static synchronized void stop() { - final FeatureFlaggingGateway.ActivationListener activationListener = ACTIVATION_LISTENER; final SpanEnrichmentWriter spanEnrichmentWriter = SPAN_ENRICHMENT_WRITER; final ExposureWriter exposureWriter = EXPOSURE_WRITER; final ConfigurationSourceService configService = CONFIG_SERVICE; STARTED = false; - ACTIVATION_LISTENER = null; SPAN_ENRICHMENT_WRITER = null; EXPOSURE_WRITER = null; CONFIG_SERVICE = null; - if (activationListener != null) { - FeatureFlaggingGateway.removeActivationListener(activationListener); - } try { if (spanEnrichmentWriter != null) { spanEnrichmentWriter.close(); @@ -165,6 +133,6 @@ public static synchronized void stop() { } static boolean isAwaitingApplicationActivation() { - return ACTIVATION_LISTENER != null; + return false; } } 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 d408d91da4e..d7e136158f1 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 @@ -8,10 +8,8 @@ import static org.junit.jupiter.api.Assertions.assertInstanceOf; 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.eq; -import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -25,7 +23,6 @@ import datadog.remoteconfig.ConfigurationPoller; import datadog.remoteconfig.Product; import datadog.trace.api.Config; -import datadog.trace.api.featureflag.FeatureFlaggingGateway; import datadog.trace.test.junit.utils.config.WithConfig; import okhttp3.HttpUrl; import okhttp3.OkHttpClient; @@ -38,18 +35,12 @@ class FeatureFlaggingSystemTest { @WithConfig( key = FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL, value = "http://127.0.0.1:1") - void agentlessStartWaitsForApplicationProviderActivation() { + void agentlessStartDoesNotWaitForApplicationProviderActivation() { SharedCommunicationObjects sharedCommunicationObjects = sharedCommunicationObjects(); - clearInvocations(sharedCommunicationObjects); try { FeatureFlaggingSystem.start(sharedCommunicationObjects); - assertTrue(FeatureFlaggingSystem.isAwaitingApplicationActivation()); - verifyNoInteractions(sharedCommunicationObjects); - - FeatureFlaggingGateway.activate(); - assertFalse(FeatureFlaggingSystem.isAwaitingApplicationActivation()); } finally { FeatureFlaggingSystem.stop(); @@ -58,29 +49,6 @@ void agentlessStartWaitsForApplicationProviderActivation() { assertFalse(FeatureFlaggingSystem.isAwaitingApplicationActivation()); } - @Test - @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "agentless") - @WithConfig( - key = FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL, - value = "http://127.0.0.1:1") - void agentlessStopRemovesPendingApplicationProviderActivation() { - SharedCommunicationObjects sharedCommunicationObjects = sharedCommunicationObjects(); - clearInvocations(sharedCommunicationObjects); - - try { - FeatureFlaggingSystem.start(sharedCommunicationObjects); - assertTrue(FeatureFlaggingSystem.isAwaitingApplicationActivation()); - - FeatureFlaggingSystem.stop(); - FeatureFlaggingGateway.activate(); - - assertFalse(FeatureFlaggingSystem.isAwaitingApplicationActivation()); - verifyNoInteractions(sharedCommunicationObjects); - } finally { - FeatureFlaggingSystem.stop(); - } - } - @Test @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "remote_config") @WithConfig(key = REMOTE_CONFIGURATION_ENABLED, value = "true") 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 c8f5625c855..77e398b76e2 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 @@ -27,6 +27,7 @@ public interface SpanEnrichmentListener extends Consumer {} private static final AtomicReference CURRENT_CONFIG = new AtomicReference<>(); + private static volatile boolean providerInjectionEnabled; private FeatureFlaggingGateway() {} @@ -60,6 +61,16 @@ public static void activate() { ACTIVATION_LISTENERS.forEach(ActivationListener::activate); } + /** Enables agent instrumentation to install the Datadog provider into OpenFeature. */ + public static void setProviderInjectionEnabled(final boolean enabled) { + providerInjectionEnabled = enabled; + } + + /** Returns whether agent instrumentation can install the Datadog OpenFeature provider. */ + public static boolean isProviderInjectionEnabled() { + return providerInjectionEnabled; + } + public static void addExposureListener(final ExposureListener listener) { EXPOSURE_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 daaaf8d7001..0bdf11150a5 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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; @@ -35,12 +37,22 @@ void setUp() { @AfterEach void tearDown() { + FeatureFlaggingGateway.setProviderInjectionEnabled(false); FeatureFlaggingGateway.removeConfigListener(configListener); FeatureFlaggingGateway.removeActivationListener(activationListener); FeatureFlaggingGateway.removeExposureListener(exposureListener); FeatureFlaggingGateway.removeSpanEnrichmentListener(spanEnrichmentListener); } + @Test + void testProviderInjectionGate() { + assertFalse(FeatureFlaggingGateway.isProviderInjectionEnabled()); + + FeatureFlaggingGateway.setProviderInjectionEnabled(true); + + assertTrue(FeatureFlaggingGateway.isProviderInjectionEnabled()); + } + @Test void testProviderActivationListener() { FeatureFlaggingGateway.addActivationListener(activationListener); diff --git a/products/feature-flagging/feature-flagging-config/src/main/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfig.java b/products/feature-flagging/feature-flagging-config/src/main/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfig.java index bae6fda1890..4a8dc1e86fe 100644 --- a/products/feature-flagging/feature-flagging-config/src/main/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfig.java +++ b/products/feature-flagging/feature-flagging-config/src/main/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfig.java @@ -52,7 +52,7 @@ public static Resolution resolveConfiguration( if (legacyProviderEnabled != null) { return legacyProviderEnabled ? REMOTE_CONFIG_CONFIGURATION : DISABLED_RESOLUTION; } - return AGENTLESS_CONFIGURATION; + return DISABLED_RESOLUTION; } public static boolean isSupportedConfigurationSource(final String source) { diff --git a/products/feature-flagging/feature-flagging-config/src/test/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfigTest.java b/products/feature-flagging/feature-flagging-config/src/test/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfigTest.java index 16283869d71..d7f551214bd 100644 --- a/products/feature-flagging/feature-flagging-config/src/test/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfigTest.java +++ b/products/feature-flagging/feature-flagging-config/src/test/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfigTest.java @@ -15,8 +15,9 @@ class FeatureFlaggingConfigTest { @Test void appliesConfigurationPrecedence() { - assertResolution(true, CONFIGURATION_SOURCE_AGENTLESS, null, null, null); - assertResolution(true, CONFIGURATION_SOURCE_AGENTLESS, null, " ", null); + assertResolution(false, null, null, null, null); + assertResolution(false, null, null, " ", null); + assertResolution(false, null, true, null, null); assertResolution(true, CONFIGURATION_SOURCE_REMOTE_CONFIG, null, null, true); assertResolution(false, null, null, null, false); assertResolution(true, CONFIGURATION_SOURCE_AGENTLESS, null, "agentless", true); diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessConfigurationSource.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessConfigurationSource.java index f4fc35cce30..77c6e46073c 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessConfigurationSource.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessConfigurationSource.java @@ -123,9 +123,9 @@ public void init() { started = true; } - // Complete the first poll cycle on the activation thread. This lets OpenFeature provider - // initialization observe a successful retry before it checks whether configuration is ready. - // No request occurs before application code activates the provider. + // Complete the first poll cycle during subsystem startup. This lets an injected OpenFeature + // provider observe configuration as soon as it initializes. Explicit configuration-source + // selection is the request and billing gate. pollOnceSafely(); synchronized (lifecycleLock) { diff --git a/settings.gradle.kts b/settings.gradle.kts index 14e988af3fe..b913ee0ff75 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -507,6 +507,7 @@ include( ":dd-java-agent:instrumentation:okhttp:okhttp-2.2", ":dd-java-agent:instrumentation:okhttp:okhttp-3.0", ":dd-java-agent:instrumentation:openai-java:openai-java-3.0", + ":dd-java-agent:instrumentation:openfeature:openfeature-1.20", ":dd-java-agent:instrumentation:opensearch:opensearch-rest-1.0", ":dd-java-agent:instrumentation:opensearch:opensearch-transport-1.0", ":dd-java-agent:instrumentation:opensearch:opensearch-common", From 272de919468c4b840356927facc77a9a1666535f Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 11 Aug 2026 19:54:06 -0400 Subject: [PATCH 2/3] Send agentless exposures directly to EVP --- .../communication/BackendApiFactory.java | 56 +++++--- .../datadog/communication/EvpProxyApi.java | 3 +- .../communication/HttpResponseException.java | 18 +++ .../communication/EvpProxyApiTest.java | 65 ++++++++++ .../feature-flagging-lib/build.gradle.kts | 2 + .../AgentlessExposureBackendApi.java | 66 ++++++++++ .../ExposureBackendApiFactory.java | 72 +++++++++++ .../featureflag/ExposureWriterImpl.java | 24 ++-- .../AgentlessExposureBackendApiTest.java | 122 ++++++++++++++++++ .../ExposureBackendApiFactoryTest.java | 103 +++++++++++++++ .../featureflag/ExposureWriterTests.java | 49 ++++++- 11 files changed, 546 insertions(+), 34 deletions(-) create mode 100644 communication/src/main/java/datadog/communication/HttpResponseException.java create mode 100644 communication/src/test/java/datadog/communication/EvpProxyApiTest.java create mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessExposureBackendApi.java create mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureBackendApiFactory.java create mode 100644 products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessExposureBackendApiTest.java create mode 100644 products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureBackendApiFactoryTest.java diff --git a/communication/src/main/java/datadog/communication/BackendApiFactory.java b/communication/src/main/java/datadog/communication/BackendApiFactory.java index 3ce78b88c22..5b7d92b29a0 100644 --- a/communication/src/main/java/datadog/communication/BackendApiFactory.java +++ b/communication/src/main/java/datadog/communication/BackendApiFactory.java @@ -24,25 +24,39 @@ public BackendApiFactory(Config config, SharedCommunicationObjects sharedCommuni } public @Nullable BackendApi createBackendApi(Intake intake) { - HttpRetryPolicy.Factory retryPolicyFactory = new HttpRetryPolicy.Factory(5, 100, 2.0, true); - if (intake.isAgentlessEnabled(config)) { - HttpUrl agentlessUrl = HttpUrl.get(intake.getAgentlessUrl(config)); - String apiKey = config.getApiKey(); - if (apiKey == null || apiKey.isEmpty()) { - throw new FatalAgentMisconfigurationError( - "Agentless mode is enabled and api key is not set. Please set application key"); - } - String traceId = config.getIdGenerationStrategy().generateTraceId().toString(); - return new IntakeApi( - agentlessUrl, - apiKey, - traceId, - retryPolicyFactory, - sharedCommunicationObjects.getIntakeHttpClient(), - true); + return createDirectIntakeApi(intake); } + BackendApi backendApi = createEvpProxyApi(intake); + if (backendApi == null) { + log.warn( + "Cannot create backend API client since agentless mode is disabled, " + + "and agent does not support EVP proxy"); + } + return backendApi; + } + + /** Creates an authenticated API client that sends data directly to a Datadog intake. */ + public BackendApi createDirectIntakeApi(Intake intake) { + HttpUrl agentlessUrl = HttpUrl.get(intake.getAgentlessUrl(config)); + String apiKey = config.getApiKey(); + if (apiKey == null || apiKey.isEmpty()) { + throw new FatalAgentMisconfigurationError( + "Agentless mode is enabled and api key is not set. Please set application key"); + } + String traceId = config.getIdGenerationStrategy().generateTraceId().toString(); + return new IntakeApi( + agentlessUrl, + apiKey, + traceId, + retryPolicyFactory(), + sharedCommunicationObjects.getIntakeHttpClient(), + true); + } + + /** Creates an API client that sends data through a compatible local EVP proxy. */ + public @Nullable BackendApi createEvpProxyApi(Intake intake) { DDAgentFeaturesDiscovery featuresDiscovery = sharedCommunicationObjects.featuresDiscovery(config); featuresDiscovery.discoverIfOutdated(); @@ -55,14 +69,14 @@ public BackendApiFactory(Config config, SharedCommunicationObjects sharedCommuni traceId, evpProxyUrl, subdomain, - retryPolicyFactory, + retryPolicyFactory(), sharedCommunicationObjects.agentHttpClient, true); } - - log.warn( - "Cannot create backend API client since agentless mode is disabled, " - + "and agent does not support EVP proxy"); return null; } + + private static HttpRetryPolicy.Factory retryPolicyFactory() { + return new HttpRetryPolicy.Factory(5, 100, 2.0, true); + } } diff --git a/communication/src/main/java/datadog/communication/EvpProxyApi.java b/communication/src/main/java/datadog/communication/EvpProxyApi.java index 83037ab9663..49f0285aac1 100644 --- a/communication/src/main/java/datadog/communication/EvpProxyApi.java +++ b/communication/src/main/java/datadog/communication/EvpProxyApi.java @@ -95,7 +95,8 @@ public T post( return responseParser.apply(responseBodyStream); } else { - throw new IOException( + throw new HttpResponseException( + response.code(), "Request to " + uri + " returned error response " diff --git a/communication/src/main/java/datadog/communication/HttpResponseException.java b/communication/src/main/java/datadog/communication/HttpResponseException.java new file mode 100644 index 00000000000..ac9b62cdb2d --- /dev/null +++ b/communication/src/main/java/datadog/communication/HttpResponseException.java @@ -0,0 +1,18 @@ +package datadog.communication; + +import java.io.IOException; + +/** An HTTP request failed with a non-success response. */ +public final class HttpResponseException extends IOException { + + private final int statusCode; + + public HttpResponseException(final int statusCode, final String message) { + super(message); + this.statusCode = statusCode; + } + + public int getStatusCode() { + return statusCode; + } +} diff --git a/communication/src/test/java/datadog/communication/EvpProxyApiTest.java b/communication/src/test/java/datadog/communication/EvpProxyApiTest.java new file mode 100644 index 00000000000..14c6962bf8e --- /dev/null +++ b/communication/src/test/java/datadog/communication/EvpProxyApiTest.java @@ -0,0 +1,65 @@ +package datadog.communication; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import datadog.communication.http.HttpRetryPolicy; +import java.io.IOException; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.RequestBody; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class EvpProxyApiTest { + + private MockWebServer server; + private OkHttpClient client; + + @BeforeEach + void setUp() throws IOException { + server = new MockWebServer(); + server.start(); + client = new OkHttpClient.Builder().build(); + } + + @AfterEach + void tearDown() throws IOException { + client.dispatcher().executorService().shutdownNow(); + client.connectionPool().evictAll(); + server.shutdown(); + } + + @Test + void reportsHttpStatusForRejectedRequest() throws Exception { + server.enqueue(new MockResponse().setResponseCode(404).setBody("not found")); + final EvpProxyApi api = + new EvpProxyApi( + "123", + server.url("/evp_proxy/v4/"), + "event-platform-intake", + HttpRetryPolicy.Factory.NEVER_RETRY, + client, + false); + + final HttpResponseException exception = + assertThrows( + HttpResponseException.class, + () -> + api.post( + "exposures", + RequestBody.create(MediaType.parse("application/json"), "{}"), + stream -> null, + null, + false)); + + assertEquals(404, exception.getStatusCode()); + final RecordedRequest request = server.takeRequest(); + assertEquals("/evp_proxy/v4/api/v2/exposures", request.getPath()); + assertEquals("event-platform-intake", request.getHeader("X-Datadog-EVP-Subdomain")); + } +} diff --git a/products/feature-flagging/feature-flagging-lib/build.gradle.kts b/products/feature-flagging/feature-flagging-lib/build.gradle.kts index 425e217e822..266966ca894 100644 --- a/products/feature-flagging/feature-flagging-lib/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-lib/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { api(project(":communication")) implementation(project(":internal-api")) api(project(":products:feature-flagging:feature-flagging-bootstrap")) + compileOnly(project(":products:feature-flagging:feature-flagging-config")) implementation(project(":utils:logging-utils")) api(project(":utils:queue-utils")) @@ -29,6 +30,7 @@ dependencies { testImplementation(libs.bundles.junit5) testImplementation(libs.bundles.mockito) + testImplementation(project(":products:feature-flagging:feature-flagging-config")) testImplementation(project(":utils:test-utils")) testImplementation(project(":dd-java-agent:testing")) } diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessExposureBackendApi.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessExposureBackendApi.java new file mode 100644 index 00000000000..97c798f0e1d --- /dev/null +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessExposureBackendApi.java @@ -0,0 +1,66 @@ +package com.datadog.featureflag; + +import datadog.communication.BackendApi; +import datadog.communication.HttpResponseException; +import datadog.communication.http.OkHttpUtils; +import datadog.communication.util.IOThrowingFunction; +import java.io.IOException; +import java.io.InputStream; +import java.net.ConnectException; +import javax.annotation.Nullable; +import okhttp3.RequestBody; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Sends exposures through a local EVP proxy, with a safe direct intake fallback. */ +final class AgentlessExposureBackendApi implements BackendApi { + + private static final Logger LOGGER = LoggerFactory.getLogger(AgentlessExposureBackendApi.class); + + private final BackendApi localApi; + private final BackendApi directApi; + private volatile BackendApi activeApi; + + AgentlessExposureBackendApi(final BackendApi localApi, final BackendApi directApi) { + this.localApi = localApi; + this.directApi = directApi; + this.activeApi = localApi; + } + + @Override + public T post( + final String uri, + final RequestBody requestBody, + final IOThrowingFunction responseParser, + @Nullable final OkHttpUtils.CustomListener requestListener, + final boolean requestCompression) + throws IOException { + final BackendApi selectedApi = activeApi; + try { + return selectedApi.post( + uri, requestBody, responseParser, requestListener, requestCompression); + } catch (final IOException exception) { + if (selectedApi != localApi || !isDefinitiveRejection(exception)) { + throw exception; + } + + if (activeApi == localApi) { + LOGGER.debug( + "Switching Feature Flagging exposure delivery from the local EVP proxy to direct intake"); + activeApi = directApi; + } + return directApi.post(uri, requestBody, responseParser, requestListener, requestCompression); + } + } + + private static boolean isDefinitiveRejection(final IOException exception) { + if (exception instanceof ConnectException) { + return true; + } + if (exception instanceof HttpResponseException) { + final int statusCode = ((HttpResponseException) exception).getStatusCode(); + return statusCode == 403 || statusCode == 404 || statusCode == 405; + } + return false; + } +} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureBackendApiFactory.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureBackendApiFactory.java new file mode 100644 index 00000000000..f0eb1fa0e3d --- /dev/null +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureBackendApiFactory.java @@ -0,0 +1,72 @@ +package com.datadog.featureflag; + +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; + +import datadog.communication.BackendApi; +import datadog.communication.BackendApiFactory; +import datadog.communication.ddagent.SharedCommunicationObjects; +import datadog.trace.api.Config; +import datadog.trace.api.intake.Intake; +import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Selects the transport for Feature Flagging exposure events. */ +final class ExposureBackendApiFactory { + + private static final Logger LOGGER = LoggerFactory.getLogger(ExposureBackendApiFactory.class); + + private final Config config; + private final BackendApiFactory backendApiFactory; + + ExposureBackendApiFactory( + final Config config, final SharedCommunicationObjects sharedCommunicationObjects) { + this(config, new BackendApiFactory(config, sharedCommunicationObjects)); + } + + ExposureBackendApiFactory(final Config config, final BackendApiFactory backendApiFactory) { + this.config = config; + this.backendApiFactory = backendApiFactory; + } + + @Nullable + BackendApi create() { + final BackendApi localApi = backendApiFactory.createEvpProxyApi(Intake.EVENT_PLATFORM); + if (!CONFIGURATION_SOURCE_AGENTLESS.equals(config.getFeatureFlaggingConfigurationSource())) { + if (localApi == null) { + LOGGER.warn( + "Feature Flagging exposure delivery is disabled because the local Agent does not support the EVP proxy"); + } + return localApi; + } + + final BackendApi directApi = createDirectApi(); + if (localApi != null && directApi != null) { + return new AgentlessExposureBackendApi(localApi, directApi); + } + if (localApi != null) { + return localApi; + } + if (directApi != null) { + return directApi; + } + + LOGGER.warn( + "Feature Flagging exposure delivery is disabled because no compatible local EVP proxy or direct intake credentials are available"); + return null; + } + + @Nullable + private BackendApi createDirectApi() { + final String apiKey = config.getApiKey(); + if (apiKey == null || apiKey.isEmpty()) { + return null; + } + try { + return backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM); + } catch (final IllegalArgumentException exception) { + LOGGER.debug("Cannot configure direct Feature Flagging exposure delivery", exception); + return null; + } + } +} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java index 9932a20256b..0d8d967ed56 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java @@ -10,13 +10,11 @@ import datadog.common.queue.MessagePassingBlockingQueue; import datadog.common.queue.Queues; import datadog.communication.BackendApi; -import datadog.communication.BackendApiFactory; import datadog.communication.ddagent.SharedCommunicationObjects; import datadog.trace.api.Config; import datadog.trace.api.featureflag.FeatureFlaggingGateway; import datadog.trace.api.featureflag.exposure.ExposureEvent; import datadog.trace.api.featureflag.exposure.ExposuresRequest; -import datadog.trace.api.intake.Intake; import datadog.trace.api.internal.VisibleForTesting; import java.util.ArrayList; import java.util.HashMap; @@ -47,6 +45,15 @@ public ExposureWriterImpl(final SharedCommunicationObjects sco, final Config con final TimeUnit timeUnit, final SharedCommunicationObjects sco, final Config config) { + this(capacity, flushInterval, timeUnit, new ExposureBackendApiFactory(config, sco), config); + } + + ExposureWriterImpl( + final int capacity, + final long flushInterval, + final TimeUnit timeUnit, + final ExposureBackendApiFactory backendApiFactory, + final Config config) { this.queue = Queues.mpscBlockingConsumerArrayQueue(capacity); final Map context = new HashMap<>(4); context.put("service", config.getServiceName() == null ? "unknown" : config.getServiceName()); @@ -58,12 +65,7 @@ public ExposureWriterImpl(final SharedCommunicationObjects sco, final Config con } final ExposureSerializingHandler serializer = new ExposureSerializingHandler( - new BackendApiFactory(config, sco), - queue, - flushInterval, - timeUnit, - context, - this::close); + backendApiFactory, queue, flushInterval, timeUnit, context, this::close); this.serializerThread = newAgentThread(FEATURE_FLAG_EXPOSURE_PROCESSOR, serializer); } @@ -102,7 +104,7 @@ private static class ExposureSerializingHandler implements Runnable { private long lastTicks; private final JsonAdapter jsonAdapter; - private final BackendApiFactory backendApiFactory; + private final ExposureBackendApiFactory backendApiFactory; private BackendApi evp; private final Map context; @@ -112,7 +114,7 @@ private static class ExposureSerializingHandler implements Runnable { private final Runnable errorCallback; public ExposureSerializingHandler( - final BackendApiFactory backendApiFactory, + final ExposureBackendApiFactory backendApiFactory, final MessagePassingBlockingQueue queue, final long flushInterval, final TimeUnit timeUnit, @@ -134,7 +136,7 @@ public ExposureSerializingHandler( @Override public void run() { - evp = backendApiFactory.createBackendApi(Intake.EVENT_PLATFORM); + evp = backendApiFactory.create(); if (evp == null) { errorCallback.run(); throw new IllegalArgumentException("EVP Proxy not available"); diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessExposureBackendApiTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessExposureBackendApiTest.java new file mode 100644 index 00000000000..9455634ec45 --- /dev/null +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessExposureBackendApiTest.java @@ -0,0 +1,122 @@ +package com.datadog.featureflag; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import datadog.communication.BackendApi; +import datadog.communication.HttpResponseException; +import datadog.communication.http.OkHttpUtils; +import datadog.communication.util.IOThrowingFunction; +import java.io.IOException; +import java.io.InputStream; +import java.net.ConnectException; +import java.net.SocketException; +import java.net.SocketTimeoutException; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nullable; +import okhttp3.MediaType; +import okhttp3.RequestBody; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class AgentlessExposureBackendApiTest { + + @ParameterizedTest + @ValueSource(ints = {403, 404, 405}) + void replaysRejectedBatchDirectlyAndKeepsDirectRoute(final int statusCode) throws Exception { + final RecordingBackendApi local = + new RecordingBackendApi(new HttpResponseException(statusCode, "rejected")); + final RecordingBackendApi direct = new RecordingBackendApi(); + final AgentlessExposureBackendApi api = new AgentlessExposureBackendApi(local, direct); + final RequestBody firstBody = requestBody("first"); + final RequestBody secondBody = requestBody("second"); + + api.post("exposures", firstBody, stream -> null, null, false); + api.post("exposures", secondBody, stream -> null, null, false); + + assertEquals(1, local.calls); + assertEquals(2, direct.calls); + assertSame(firstBody, local.requestBodies.get(0)); + assertSame(firstBody, direct.requestBodies.get(0)); + assertSame(secondBody, direct.requestBodies.get(1)); + } + + @Test + void fallsBackAfterConnectionRefusal() throws Exception { + final RecordingBackendApi local = + new RecordingBackendApi(new ConnectException("connection refused")); + final RecordingBackendApi direct = new RecordingBackendApi(); + final AgentlessExposureBackendApi api = new AgentlessExposureBackendApi(local, direct); + + api.post("exposures", requestBody("exposure"), stream -> null, null, false); + + assertEquals(1, local.calls); + assertEquals(1, direct.calls); + } + + @ParameterizedTest + @ValueSource(ints = {429, 500}) + void doesNotReplayAmbiguousHttpFailure(final int statusCode) { + assertNoDirectReplay(new HttpResponseException(statusCode, "ambiguous")); + } + + @Test + void doesNotReplayTimeout() { + assertNoDirectReplay(new SocketTimeoutException("timed out")); + } + + @Test + void doesNotReplayConnectionReset() { + assertNoDirectReplay(new SocketException("connection reset")); + } + + private static void assertNoDirectReplay(final IOException failure) { + final RecordingBackendApi local = new RecordingBackendApi(failure); + final RecordingBackendApi direct = new RecordingBackendApi(); + final AgentlessExposureBackendApi api = new AgentlessExposureBackendApi(local, direct); + + assertThrows( + IOException.class, + () -> api.post("exposures", requestBody("exposure"), stream -> null, null, false)); + + assertEquals(1, local.calls); + assertEquals(0, direct.calls); + } + + private static RequestBody requestBody(final String value) { + return RequestBody.create(MediaType.parse("application/json"), value); + } + + private static final class RecordingBackendApi implements BackendApi { + private final IOException failure; + private final List requestBodies = new ArrayList<>(); + private int calls; + + private RecordingBackendApi() { + this(null); + } + + private RecordingBackendApi(@Nullable final IOException failure) { + this.failure = failure; + } + + @Override + public T post( + final String uri, + final RequestBody requestBody, + final IOThrowingFunction responseParser, + @Nullable final OkHttpUtils.CustomListener requestListener, + final boolean requestCompression) + throws IOException { + calls++; + requestBodies.add(requestBody); + if (failure != null) { + throw failure; + } + return null; + } + } +} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureBackendApiFactoryTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureBackendApiFactoryTest.java new file mode 100644 index 00000000000..602a3219ef3 --- /dev/null +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureBackendApiFactoryTest.java @@ -0,0 +1,103 @@ +package com.datadog.featureflag; + +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_REMOTE_CONFIG; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import datadog.communication.BackendApi; +import datadog.communication.BackendApiFactory; +import datadog.trace.api.Config; +import datadog.trace.api.intake.Intake; +import org.junit.jupiter.api.Test; + +class ExposureBackendApiFactoryTest { + + @Test + void remoteConfigUsesOnlyLocalEvpProxy() { + final Config config = config(CONFIGURATION_SOURCE_REMOTE_CONFIG, "api-key"); + final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); + final BackendApi localApi = mock(BackendApi.class); + when(backendApiFactory.createEvpProxyApi(Intake.EVENT_PLATFORM)).thenReturn(localApi); + + final BackendApi selected = new ExposureBackendApiFactory(config, backendApiFactory).create(); + + assertSame(localApi, selected); + verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM); + } + + @Test + void agentlessPrefersLocalEvpProxyWithDirectFallback() { + final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, "api-key"); + final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); + when(backendApiFactory.createEvpProxyApi(Intake.EVENT_PLATFORM)) + .thenReturn(mock(BackendApi.class)); + when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM)) + .thenReturn(mock(BackendApi.class)); + + final BackendApi selected = new ExposureBackendApiFactory(config, backendApiFactory).create(); + + assertInstanceOf(AgentlessExposureBackendApi.class, selected); + } + + @Test + void agentlessUsesDirectIntakeWhenLocalEvpProxyIsUnavailable() { + final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, "api-key"); + final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); + final BackendApi directApi = mock(BackendApi.class); + when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM)).thenReturn(directApi); + + final BackendApi selected = new ExposureBackendApiFactory(config, backendApiFactory).create(); + + assertSame(directApi, selected); + } + + @Test + void agentlessUsesLocalEvpProxyWhenApiKeyIsUnavailable() { + final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, null); + final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); + final BackendApi localApi = mock(BackendApi.class); + when(backendApiFactory.createEvpProxyApi(Intake.EVENT_PLATFORM)).thenReturn(localApi); + + final BackendApi selected = new ExposureBackendApiFactory(config, backendApiFactory).create(); + + assertSame(localApi, selected); + verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM); + } + + @Test + void agentlessDisablesDeliveryWhenNoRouteIsAvailable() { + final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, null); + final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); + + final BackendApi selected = new ExposureBackendApiFactory(config, backendApiFactory).create(); + + assertNull(selected); + } + + @Test + void agentlessKeepsLocalRouteWhenDirectUrlIsInvalid() { + final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, "api-key"); + final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); + final BackendApi localApi = mock(BackendApi.class); + when(backendApiFactory.createEvpProxyApi(Intake.EVENT_PLATFORM)).thenReturn(localApi); + when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM)) + .thenThrow(new IllegalArgumentException("invalid URL")); + + final BackendApi selected = new ExposureBackendApiFactory(config, backendApiFactory).create(); + + assertSame(localApi, selected); + } + + private static Config config(final String source, final String apiKey) { + final Config config = mock(Config.class); + when(config.getFeatureFlaggingConfigurationSource()).thenReturn(source); + when(config.getApiKey()).thenReturn(apiKey); + return config; + } +} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java index 76b9e2602d8..cfd65cceb2c 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java @@ -1,5 +1,6 @@ package com.datadog.featureflag; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; import static java.util.Collections.singletonList; import static java.util.Collections.singletonMap; import static java.util.concurrent.TimeUnit.MILLISECONDS; @@ -13,8 +14,11 @@ import com.squareup.moshi.JsonAdapter; import com.squareup.moshi.Moshi; +import datadog.communication.BackendApiFactory; +import datadog.communication.IntakeApi; import datadog.communication.ddagent.DDAgentFeaturesDiscovery; import datadog.communication.ddagent.SharedCommunicationObjects; +import datadog.communication.http.HttpRetryPolicy; import datadog.trace.agent.test.server.http.JavaTestHttpServer; import datadog.trace.agent.test.server.http.JavaTestHttpServer.HandlerApi; import datadog.trace.api.Config; @@ -57,6 +61,8 @@ class ExposureWriterTests { private static final String EXPOSURES_ENDPOINT = "/evp_proxy/api/v2/exposures"; + private static final String DIRECT_EXPOSURES_ENDPOINT = "/api/v2/exposures"; + private static final String API_KEY = "test-api-key"; private static final double TIMEOUT_SECONDS = 5; private final PollingConditions poll = new PollingConditions(TIMEOUT_SECONDS); @@ -75,7 +81,11 @@ void setUp() { JavaTestHttpServer.httpServer( s -> s.handlers( - h -> h.prefix(EXPOSURES_ENDPOINT, api -> handleExposureRequest(api, adapter)))); + h -> { + h.prefix(EXPOSURES_ENDPOINT, api -> handleExposureRequest(api, adapter)); + h.prefix( + DIRECT_EXPOSURES_ENDPOINT, api -> handleExposureRequest(api, adapter)); + })); sharedCommunicationObjects = sharedCommunicationObjects(true); } @@ -131,6 +141,43 @@ void testExposureEventWrites(String service, String env, String version) throws } } + @Test + void testAgentlessExposureEventWritesDirectlyWithApiKey() throws Exception { + Config config = mockConfig("test-service"); + when(config.getFeatureFlaggingConfigurationSource()).thenReturn(CONFIGURATION_SOURCE_AGENTLESS); + when(config.getApiKey()).thenReturn(API_KEY); + BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); + IntakeApi directApi = + new IntakeApi( + HttpUrl.get(server.getAddress()).resolve("/api/v2/"), + API_KEY, + "123", + HttpRetryPolicy.Factory.NEVER_RETRY, + new OkHttpClient.Builder().build(), + false); + when(backendApiFactory.createDirectIntakeApi(datadog.trace.api.intake.Intake.EVENT_PLATFORM)) + .thenReturn(directApi); + ExposureBackendApiFactory exposureBackendApiFactory = + new ExposureBackendApiFactory(config, backendApiFactory); + List exposures = buildExposures(5); + + try (ExposureWriterImpl writer = + new ExposureWriterImpl(1 << 4, 100, MILLISECONDS, exposureBackendApiFactory, config)) { + writer.init(); + for (ExposureEvent exposure : exposures) { + writer.accept(exposure); + } + + poll.eventually( + () -> { + assertEquals(DIRECT_EXPOSURES_ENDPOINT, server.getLastRequest().getPath()); + assertEquals(API_KEY, server.getLastRequest().getHeader("dd-api-key")); + assertNull(server.getLastRequest().getHeader("X-Datadog-EVP-Subdomain")); + assertExposures(allExposures(), exposures); + }); + } + } + @Test void testLruCache() throws Exception { Config config = mockConfig("test-service"); From b86bca8599e288d11a936c9bc72f8b0ce4b9ada4 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Tue, 11 Aug 2026 20:05:27 -0400 Subject: [PATCH 3/3] Revert "Send agentless exposures directly to EVP" This reverts commit 272de919468c4b840356927facc77a9a1666535f. --- .../communication/BackendApiFactory.java | 56 +++----- .../datadog/communication/EvpProxyApi.java | 3 +- .../communication/HttpResponseException.java | 18 --- .../communication/EvpProxyApiTest.java | 65 ---------- .../feature-flagging-lib/build.gradle.kts | 2 - .../AgentlessExposureBackendApi.java | 66 ---------- .../ExposureBackendApiFactory.java | 72 ----------- .../featureflag/ExposureWriterImpl.java | 24 ++-- .../AgentlessExposureBackendApiTest.java | 122 ------------------ .../ExposureBackendApiFactoryTest.java | 103 --------------- .../featureflag/ExposureWriterTests.java | 49 +------ 11 files changed, 34 insertions(+), 546 deletions(-) delete mode 100644 communication/src/main/java/datadog/communication/HttpResponseException.java delete mode 100644 communication/src/test/java/datadog/communication/EvpProxyApiTest.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessExposureBackendApi.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureBackendApiFactory.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessExposureBackendApiTest.java delete mode 100644 products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureBackendApiFactoryTest.java diff --git a/communication/src/main/java/datadog/communication/BackendApiFactory.java b/communication/src/main/java/datadog/communication/BackendApiFactory.java index 5b7d92b29a0..3ce78b88c22 100644 --- a/communication/src/main/java/datadog/communication/BackendApiFactory.java +++ b/communication/src/main/java/datadog/communication/BackendApiFactory.java @@ -24,39 +24,25 @@ public BackendApiFactory(Config config, SharedCommunicationObjects sharedCommuni } public @Nullable BackendApi createBackendApi(Intake intake) { - if (intake.isAgentlessEnabled(config)) { - return createDirectIntakeApi(intake); - } + HttpRetryPolicy.Factory retryPolicyFactory = new HttpRetryPolicy.Factory(5, 100, 2.0, true); - BackendApi backendApi = createEvpProxyApi(intake); - if (backendApi == null) { - log.warn( - "Cannot create backend API client since agentless mode is disabled, " - + "and agent does not support EVP proxy"); - } - return backendApi; - } - - /** Creates an authenticated API client that sends data directly to a Datadog intake. */ - public BackendApi createDirectIntakeApi(Intake intake) { - HttpUrl agentlessUrl = HttpUrl.get(intake.getAgentlessUrl(config)); - String apiKey = config.getApiKey(); - if (apiKey == null || apiKey.isEmpty()) { - throw new FatalAgentMisconfigurationError( - "Agentless mode is enabled and api key is not set. Please set application key"); + if (intake.isAgentlessEnabled(config)) { + HttpUrl agentlessUrl = HttpUrl.get(intake.getAgentlessUrl(config)); + String apiKey = config.getApiKey(); + if (apiKey == null || apiKey.isEmpty()) { + throw new FatalAgentMisconfigurationError( + "Agentless mode is enabled and api key is not set. Please set application key"); + } + String traceId = config.getIdGenerationStrategy().generateTraceId().toString(); + return new IntakeApi( + agentlessUrl, + apiKey, + traceId, + retryPolicyFactory, + sharedCommunicationObjects.getIntakeHttpClient(), + true); } - String traceId = config.getIdGenerationStrategy().generateTraceId().toString(); - return new IntakeApi( - agentlessUrl, - apiKey, - traceId, - retryPolicyFactory(), - sharedCommunicationObjects.getIntakeHttpClient(), - true); - } - /** Creates an API client that sends data through a compatible local EVP proxy. */ - public @Nullable BackendApi createEvpProxyApi(Intake intake) { DDAgentFeaturesDiscovery featuresDiscovery = sharedCommunicationObjects.featuresDiscovery(config); featuresDiscovery.discoverIfOutdated(); @@ -69,14 +55,14 @@ public BackendApi createDirectIntakeApi(Intake intake) { traceId, evpProxyUrl, subdomain, - retryPolicyFactory(), + retryPolicyFactory, sharedCommunicationObjects.agentHttpClient, true); } - return null; - } - private static HttpRetryPolicy.Factory retryPolicyFactory() { - return new HttpRetryPolicy.Factory(5, 100, 2.0, true); + log.warn( + "Cannot create backend API client since agentless mode is disabled, " + + "and agent does not support EVP proxy"); + return null; } } diff --git a/communication/src/main/java/datadog/communication/EvpProxyApi.java b/communication/src/main/java/datadog/communication/EvpProxyApi.java index 49f0285aac1..83037ab9663 100644 --- a/communication/src/main/java/datadog/communication/EvpProxyApi.java +++ b/communication/src/main/java/datadog/communication/EvpProxyApi.java @@ -95,8 +95,7 @@ public T post( return responseParser.apply(responseBodyStream); } else { - throw new HttpResponseException( - response.code(), + throw new IOException( "Request to " + uri + " returned error response " diff --git a/communication/src/main/java/datadog/communication/HttpResponseException.java b/communication/src/main/java/datadog/communication/HttpResponseException.java deleted file mode 100644 index ac9b62cdb2d..00000000000 --- a/communication/src/main/java/datadog/communication/HttpResponseException.java +++ /dev/null @@ -1,18 +0,0 @@ -package datadog.communication; - -import java.io.IOException; - -/** An HTTP request failed with a non-success response. */ -public final class HttpResponseException extends IOException { - - private final int statusCode; - - public HttpResponseException(final int statusCode, final String message) { - super(message); - this.statusCode = statusCode; - } - - public int getStatusCode() { - return statusCode; - } -} diff --git a/communication/src/test/java/datadog/communication/EvpProxyApiTest.java b/communication/src/test/java/datadog/communication/EvpProxyApiTest.java deleted file mode 100644 index 14c6962bf8e..00000000000 --- a/communication/src/test/java/datadog/communication/EvpProxyApiTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package datadog.communication; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import datadog.communication.http.HttpRetryPolicy; -import java.io.IOException; -import okhttp3.MediaType; -import okhttp3.OkHttpClient; -import okhttp3.RequestBody; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -class EvpProxyApiTest { - - private MockWebServer server; - private OkHttpClient client; - - @BeforeEach - void setUp() throws IOException { - server = new MockWebServer(); - server.start(); - client = new OkHttpClient.Builder().build(); - } - - @AfterEach - void tearDown() throws IOException { - client.dispatcher().executorService().shutdownNow(); - client.connectionPool().evictAll(); - server.shutdown(); - } - - @Test - void reportsHttpStatusForRejectedRequest() throws Exception { - server.enqueue(new MockResponse().setResponseCode(404).setBody("not found")); - final EvpProxyApi api = - new EvpProxyApi( - "123", - server.url("/evp_proxy/v4/"), - "event-platform-intake", - HttpRetryPolicy.Factory.NEVER_RETRY, - client, - false); - - final HttpResponseException exception = - assertThrows( - HttpResponseException.class, - () -> - api.post( - "exposures", - RequestBody.create(MediaType.parse("application/json"), "{}"), - stream -> null, - null, - false)); - - assertEquals(404, exception.getStatusCode()); - final RecordedRequest request = server.takeRequest(); - assertEquals("/evp_proxy/v4/api/v2/exposures", request.getPath()); - assertEquals("event-platform-intake", request.getHeader("X-Datadog-EVP-Subdomain")); - } -} diff --git a/products/feature-flagging/feature-flagging-lib/build.gradle.kts b/products/feature-flagging/feature-flagging-lib/build.gradle.kts index 266966ca894..425e217e822 100644 --- a/products/feature-flagging/feature-flagging-lib/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-lib/build.gradle.kts @@ -20,7 +20,6 @@ dependencies { api(project(":communication")) implementation(project(":internal-api")) api(project(":products:feature-flagging:feature-flagging-bootstrap")) - compileOnly(project(":products:feature-flagging:feature-flagging-config")) implementation(project(":utils:logging-utils")) api(project(":utils:queue-utils")) @@ -30,7 +29,6 @@ dependencies { testImplementation(libs.bundles.junit5) testImplementation(libs.bundles.mockito) - testImplementation(project(":products:feature-flagging:feature-flagging-config")) testImplementation(project(":utils:test-utils")) testImplementation(project(":dd-java-agent:testing")) } diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessExposureBackendApi.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessExposureBackendApi.java deleted file mode 100644 index 97c798f0e1d..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/AgentlessExposureBackendApi.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.datadog.featureflag; - -import datadog.communication.BackendApi; -import datadog.communication.HttpResponseException; -import datadog.communication.http.OkHttpUtils; -import datadog.communication.util.IOThrowingFunction; -import java.io.IOException; -import java.io.InputStream; -import java.net.ConnectException; -import javax.annotation.Nullable; -import okhttp3.RequestBody; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** Sends exposures through a local EVP proxy, with a safe direct intake fallback. */ -final class AgentlessExposureBackendApi implements BackendApi { - - private static final Logger LOGGER = LoggerFactory.getLogger(AgentlessExposureBackendApi.class); - - private final BackendApi localApi; - private final BackendApi directApi; - private volatile BackendApi activeApi; - - AgentlessExposureBackendApi(final BackendApi localApi, final BackendApi directApi) { - this.localApi = localApi; - this.directApi = directApi; - this.activeApi = localApi; - } - - @Override - public T post( - final String uri, - final RequestBody requestBody, - final IOThrowingFunction responseParser, - @Nullable final OkHttpUtils.CustomListener requestListener, - final boolean requestCompression) - throws IOException { - final BackendApi selectedApi = activeApi; - try { - return selectedApi.post( - uri, requestBody, responseParser, requestListener, requestCompression); - } catch (final IOException exception) { - if (selectedApi != localApi || !isDefinitiveRejection(exception)) { - throw exception; - } - - if (activeApi == localApi) { - LOGGER.debug( - "Switching Feature Flagging exposure delivery from the local EVP proxy to direct intake"); - activeApi = directApi; - } - return directApi.post(uri, requestBody, responseParser, requestListener, requestCompression); - } - } - - private static boolean isDefinitiveRejection(final IOException exception) { - if (exception instanceof ConnectException) { - return true; - } - if (exception instanceof HttpResponseException) { - final int statusCode = ((HttpResponseException) exception).getStatusCode(); - return statusCode == 403 || statusCode == 404 || statusCode == 405; - } - return false; - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureBackendApiFactory.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureBackendApiFactory.java deleted file mode 100644 index f0eb1fa0e3d..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureBackendApiFactory.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.datadog.featureflag; - -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; - -import datadog.communication.BackendApi; -import datadog.communication.BackendApiFactory; -import datadog.communication.ddagent.SharedCommunicationObjects; -import datadog.trace.api.Config; -import datadog.trace.api.intake.Intake; -import javax.annotation.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** Selects the transport for Feature Flagging exposure events. */ -final class ExposureBackendApiFactory { - - private static final Logger LOGGER = LoggerFactory.getLogger(ExposureBackendApiFactory.class); - - private final Config config; - private final BackendApiFactory backendApiFactory; - - ExposureBackendApiFactory( - final Config config, final SharedCommunicationObjects sharedCommunicationObjects) { - this(config, new BackendApiFactory(config, sharedCommunicationObjects)); - } - - ExposureBackendApiFactory(final Config config, final BackendApiFactory backendApiFactory) { - this.config = config; - this.backendApiFactory = backendApiFactory; - } - - @Nullable - BackendApi create() { - final BackendApi localApi = backendApiFactory.createEvpProxyApi(Intake.EVENT_PLATFORM); - if (!CONFIGURATION_SOURCE_AGENTLESS.equals(config.getFeatureFlaggingConfigurationSource())) { - if (localApi == null) { - LOGGER.warn( - "Feature Flagging exposure delivery is disabled because the local Agent does not support the EVP proxy"); - } - return localApi; - } - - final BackendApi directApi = createDirectApi(); - if (localApi != null && directApi != null) { - return new AgentlessExposureBackendApi(localApi, directApi); - } - if (localApi != null) { - return localApi; - } - if (directApi != null) { - return directApi; - } - - LOGGER.warn( - "Feature Flagging exposure delivery is disabled because no compatible local EVP proxy or direct intake credentials are available"); - return null; - } - - @Nullable - private BackendApi createDirectApi() { - final String apiKey = config.getApiKey(); - if (apiKey == null || apiKey.isEmpty()) { - return null; - } - try { - return backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM); - } catch (final IllegalArgumentException exception) { - LOGGER.debug("Cannot configure direct Feature Flagging exposure delivery", exception); - return null; - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java index 0d8d967ed56..9932a20256b 100644 --- a/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java +++ b/products/feature-flagging/feature-flagging-lib/src/main/java/com/datadog/featureflag/ExposureWriterImpl.java @@ -10,11 +10,13 @@ import datadog.common.queue.MessagePassingBlockingQueue; import datadog.common.queue.Queues; import datadog.communication.BackendApi; +import datadog.communication.BackendApiFactory; import datadog.communication.ddagent.SharedCommunicationObjects; import datadog.trace.api.Config; import datadog.trace.api.featureflag.FeatureFlaggingGateway; import datadog.trace.api.featureflag.exposure.ExposureEvent; import datadog.trace.api.featureflag.exposure.ExposuresRequest; +import datadog.trace.api.intake.Intake; import datadog.trace.api.internal.VisibleForTesting; import java.util.ArrayList; import java.util.HashMap; @@ -45,15 +47,6 @@ public ExposureWriterImpl(final SharedCommunicationObjects sco, final Config con final TimeUnit timeUnit, final SharedCommunicationObjects sco, final Config config) { - this(capacity, flushInterval, timeUnit, new ExposureBackendApiFactory(config, sco), config); - } - - ExposureWriterImpl( - final int capacity, - final long flushInterval, - final TimeUnit timeUnit, - final ExposureBackendApiFactory backendApiFactory, - final Config config) { this.queue = Queues.mpscBlockingConsumerArrayQueue(capacity); final Map context = new HashMap<>(4); context.put("service", config.getServiceName() == null ? "unknown" : config.getServiceName()); @@ -65,7 +58,12 @@ public ExposureWriterImpl(final SharedCommunicationObjects sco, final Config con } final ExposureSerializingHandler serializer = new ExposureSerializingHandler( - backendApiFactory, queue, flushInterval, timeUnit, context, this::close); + new BackendApiFactory(config, sco), + queue, + flushInterval, + timeUnit, + context, + this::close); this.serializerThread = newAgentThread(FEATURE_FLAG_EXPOSURE_PROCESSOR, serializer); } @@ -104,7 +102,7 @@ private static class ExposureSerializingHandler implements Runnable { private long lastTicks; private final JsonAdapter jsonAdapter; - private final ExposureBackendApiFactory backendApiFactory; + private final BackendApiFactory backendApiFactory; private BackendApi evp; private final Map context; @@ -114,7 +112,7 @@ private static class ExposureSerializingHandler implements Runnable { private final Runnable errorCallback; public ExposureSerializingHandler( - final ExposureBackendApiFactory backendApiFactory, + final BackendApiFactory backendApiFactory, final MessagePassingBlockingQueue queue, final long flushInterval, final TimeUnit timeUnit, @@ -136,7 +134,7 @@ public ExposureSerializingHandler( @Override public void run() { - evp = backendApiFactory.create(); + evp = backendApiFactory.createBackendApi(Intake.EVENT_PLATFORM); if (evp == null) { errorCallback.run(); throw new IllegalArgumentException("EVP Proxy not available"); diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessExposureBackendApiTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessExposureBackendApiTest.java deleted file mode 100644 index 9455634ec45..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/AgentlessExposureBackendApiTest.java +++ /dev/null @@ -1,122 +0,0 @@ -package com.datadog.featureflag; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import datadog.communication.BackendApi; -import datadog.communication.HttpResponseException; -import datadog.communication.http.OkHttpUtils; -import datadog.communication.util.IOThrowingFunction; -import java.io.IOException; -import java.io.InputStream; -import java.net.ConnectException; -import java.net.SocketException; -import java.net.SocketTimeoutException; -import java.util.ArrayList; -import java.util.List; -import javax.annotation.Nullable; -import okhttp3.MediaType; -import okhttp3.RequestBody; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -class AgentlessExposureBackendApiTest { - - @ParameterizedTest - @ValueSource(ints = {403, 404, 405}) - void replaysRejectedBatchDirectlyAndKeepsDirectRoute(final int statusCode) throws Exception { - final RecordingBackendApi local = - new RecordingBackendApi(new HttpResponseException(statusCode, "rejected")); - final RecordingBackendApi direct = new RecordingBackendApi(); - final AgentlessExposureBackendApi api = new AgentlessExposureBackendApi(local, direct); - final RequestBody firstBody = requestBody("first"); - final RequestBody secondBody = requestBody("second"); - - api.post("exposures", firstBody, stream -> null, null, false); - api.post("exposures", secondBody, stream -> null, null, false); - - assertEquals(1, local.calls); - assertEquals(2, direct.calls); - assertSame(firstBody, local.requestBodies.get(0)); - assertSame(firstBody, direct.requestBodies.get(0)); - assertSame(secondBody, direct.requestBodies.get(1)); - } - - @Test - void fallsBackAfterConnectionRefusal() throws Exception { - final RecordingBackendApi local = - new RecordingBackendApi(new ConnectException("connection refused")); - final RecordingBackendApi direct = new RecordingBackendApi(); - final AgentlessExposureBackendApi api = new AgentlessExposureBackendApi(local, direct); - - api.post("exposures", requestBody("exposure"), stream -> null, null, false); - - assertEquals(1, local.calls); - assertEquals(1, direct.calls); - } - - @ParameterizedTest - @ValueSource(ints = {429, 500}) - void doesNotReplayAmbiguousHttpFailure(final int statusCode) { - assertNoDirectReplay(new HttpResponseException(statusCode, "ambiguous")); - } - - @Test - void doesNotReplayTimeout() { - assertNoDirectReplay(new SocketTimeoutException("timed out")); - } - - @Test - void doesNotReplayConnectionReset() { - assertNoDirectReplay(new SocketException("connection reset")); - } - - private static void assertNoDirectReplay(final IOException failure) { - final RecordingBackendApi local = new RecordingBackendApi(failure); - final RecordingBackendApi direct = new RecordingBackendApi(); - final AgentlessExposureBackendApi api = new AgentlessExposureBackendApi(local, direct); - - assertThrows( - IOException.class, - () -> api.post("exposures", requestBody("exposure"), stream -> null, null, false)); - - assertEquals(1, local.calls); - assertEquals(0, direct.calls); - } - - private static RequestBody requestBody(final String value) { - return RequestBody.create(MediaType.parse("application/json"), value); - } - - private static final class RecordingBackendApi implements BackendApi { - private final IOException failure; - private final List requestBodies = new ArrayList<>(); - private int calls; - - private RecordingBackendApi() { - this(null); - } - - private RecordingBackendApi(@Nullable final IOException failure) { - this.failure = failure; - } - - @Override - public T post( - final String uri, - final RequestBody requestBody, - final IOThrowingFunction responseParser, - @Nullable final OkHttpUtils.CustomListener requestListener, - final boolean requestCompression) - throws IOException { - calls++; - requestBodies.add(requestBody); - if (failure != null) { - throw failure; - } - return null; - } - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureBackendApiFactoryTest.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureBackendApiFactoryTest.java deleted file mode 100644 index 602a3219ef3..00000000000 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureBackendApiFactoryTest.java +++ /dev/null @@ -1,103 +0,0 @@ -package com.datadog.featureflag; - -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_REMOTE_CONFIG; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import datadog.communication.BackendApi; -import datadog.communication.BackendApiFactory; -import datadog.trace.api.Config; -import datadog.trace.api.intake.Intake; -import org.junit.jupiter.api.Test; - -class ExposureBackendApiFactoryTest { - - @Test - void remoteConfigUsesOnlyLocalEvpProxy() { - final Config config = config(CONFIGURATION_SOURCE_REMOTE_CONFIG, "api-key"); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - final BackendApi localApi = mock(BackendApi.class); - when(backendApiFactory.createEvpProxyApi(Intake.EVENT_PLATFORM)).thenReturn(localApi); - - final BackendApi selected = new ExposureBackendApiFactory(config, backendApiFactory).create(); - - assertSame(localApi, selected); - verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM); - } - - @Test - void agentlessPrefersLocalEvpProxyWithDirectFallback() { - final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, "api-key"); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - when(backendApiFactory.createEvpProxyApi(Intake.EVENT_PLATFORM)) - .thenReturn(mock(BackendApi.class)); - when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM)) - .thenReturn(mock(BackendApi.class)); - - final BackendApi selected = new ExposureBackendApiFactory(config, backendApiFactory).create(); - - assertInstanceOf(AgentlessExposureBackendApi.class, selected); - } - - @Test - void agentlessUsesDirectIntakeWhenLocalEvpProxyIsUnavailable() { - final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, "api-key"); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - final BackendApi directApi = mock(BackendApi.class); - when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM)).thenReturn(directApi); - - final BackendApi selected = new ExposureBackendApiFactory(config, backendApiFactory).create(); - - assertSame(directApi, selected); - } - - @Test - void agentlessUsesLocalEvpProxyWhenApiKeyIsUnavailable() { - final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, null); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - final BackendApi localApi = mock(BackendApi.class); - when(backendApiFactory.createEvpProxyApi(Intake.EVENT_PLATFORM)).thenReturn(localApi); - - final BackendApi selected = new ExposureBackendApiFactory(config, backendApiFactory).create(); - - assertSame(localApi, selected); - verify(backendApiFactory, never()).createDirectIntakeApi(Intake.EVENT_PLATFORM); - } - - @Test - void agentlessDisablesDeliveryWhenNoRouteIsAvailable() { - final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, null); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - - final BackendApi selected = new ExposureBackendApiFactory(config, backendApiFactory).create(); - - assertNull(selected); - } - - @Test - void agentlessKeepsLocalRouteWhenDirectUrlIsInvalid() { - final Config config = config(CONFIGURATION_SOURCE_AGENTLESS, "api-key"); - final BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - final BackendApi localApi = mock(BackendApi.class); - when(backendApiFactory.createEvpProxyApi(Intake.EVENT_PLATFORM)).thenReturn(localApi); - when(backendApiFactory.createDirectIntakeApi(Intake.EVENT_PLATFORM)) - .thenThrow(new IllegalArgumentException("invalid URL")); - - final BackendApi selected = new ExposureBackendApiFactory(config, backendApiFactory).create(); - - assertSame(localApi, selected); - } - - private static Config config(final String source, final String apiKey) { - final Config config = mock(Config.class); - when(config.getFeatureFlaggingConfigurationSource()).thenReturn(source); - when(config.getApiKey()).thenReturn(apiKey); - return config; - } -} diff --git a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java index cfd65cceb2c..76b9e2602d8 100644 --- a/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java +++ b/products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/ExposureWriterTests.java @@ -1,6 +1,5 @@ package com.datadog.featureflag; -import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; import static java.util.Collections.singletonList; import static java.util.Collections.singletonMap; import static java.util.concurrent.TimeUnit.MILLISECONDS; @@ -14,11 +13,8 @@ import com.squareup.moshi.JsonAdapter; import com.squareup.moshi.Moshi; -import datadog.communication.BackendApiFactory; -import datadog.communication.IntakeApi; import datadog.communication.ddagent.DDAgentFeaturesDiscovery; import datadog.communication.ddagent.SharedCommunicationObjects; -import datadog.communication.http.HttpRetryPolicy; import datadog.trace.agent.test.server.http.JavaTestHttpServer; import datadog.trace.agent.test.server.http.JavaTestHttpServer.HandlerApi; import datadog.trace.api.Config; @@ -61,8 +57,6 @@ class ExposureWriterTests { private static final String EXPOSURES_ENDPOINT = "/evp_proxy/api/v2/exposures"; - private static final String DIRECT_EXPOSURES_ENDPOINT = "/api/v2/exposures"; - private static final String API_KEY = "test-api-key"; private static final double TIMEOUT_SECONDS = 5; private final PollingConditions poll = new PollingConditions(TIMEOUT_SECONDS); @@ -81,11 +75,7 @@ void setUp() { JavaTestHttpServer.httpServer( s -> s.handlers( - h -> { - h.prefix(EXPOSURES_ENDPOINT, api -> handleExposureRequest(api, adapter)); - h.prefix( - DIRECT_EXPOSURES_ENDPOINT, api -> handleExposureRequest(api, adapter)); - })); + h -> h.prefix(EXPOSURES_ENDPOINT, api -> handleExposureRequest(api, adapter)))); sharedCommunicationObjects = sharedCommunicationObjects(true); } @@ -141,43 +131,6 @@ void testExposureEventWrites(String service, String env, String version) throws } } - @Test - void testAgentlessExposureEventWritesDirectlyWithApiKey() throws Exception { - Config config = mockConfig("test-service"); - when(config.getFeatureFlaggingConfigurationSource()).thenReturn(CONFIGURATION_SOURCE_AGENTLESS); - when(config.getApiKey()).thenReturn(API_KEY); - BackendApiFactory backendApiFactory = mock(BackendApiFactory.class); - IntakeApi directApi = - new IntakeApi( - HttpUrl.get(server.getAddress()).resolve("/api/v2/"), - API_KEY, - "123", - HttpRetryPolicy.Factory.NEVER_RETRY, - new OkHttpClient.Builder().build(), - false); - when(backendApiFactory.createDirectIntakeApi(datadog.trace.api.intake.Intake.EVENT_PLATFORM)) - .thenReturn(directApi); - ExposureBackendApiFactory exposureBackendApiFactory = - new ExposureBackendApiFactory(config, backendApiFactory); - List exposures = buildExposures(5); - - try (ExposureWriterImpl writer = - new ExposureWriterImpl(1 << 4, 100, MILLISECONDS, exposureBackendApiFactory, config)) { - writer.init(); - for (ExposureEvent exposure : exposures) { - writer.accept(exposure); - } - - poll.eventually( - () -> { - assertEquals(DIRECT_EXPOSURES_ENDPOINT, server.getLastRequest().getPath()); - assertEquals(API_KEY, server.getLastRequest().getHeader("dd-api-key")); - assertNull(server.getLastRequest().getHeader("X-Datadog-EVP-Subdomain")); - assertExposures(allExposures(), exposures); - }); - } - } - @Test void testLruCache() throws Exception { Config config = mockConfig("test-service");