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 18e9128f5e0..8e69a8aade8 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 @@ -135,7 +135,7 @@ private enum AgentFeature { APP_LOGS_COLLECTION(GeneralConfig.APP_LOGS_COLLECTION_ENABLED, false), LLMOBS(LlmObsConfig.LLMOBS_ENABLED, false), LLMOBS_AGENTLESS(LlmObsConfig.LLMOBS_AGENTLESS_ENABLED, false), - FEATURE_FLAGGING(FeatureFlaggingConfig.FLAGGING_PROVIDER_ENABLED, false); + FEATURE_FLAGGING(FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED, true); private final String configKey; private final String systemProp; @@ -282,7 +282,7 @@ public static void start( agentlessLogSubmissionEnabled = isFeatureEnabled(AgentFeature.AGENTLESS_LOG_SUBMISSION); appLogsCollectionEnabled = isFeatureEnabled(AgentFeature.APP_LOGS_COLLECTION); llmObsEnabled = isFeatureEnabled(AgentFeature.LLMOBS); - featureFlaggingEnabled = isFeatureEnabled(AgentFeature.FEATURE_FLAGGING); + featureFlaggingEnabled = isFeatureFlaggingEnabled(); // setup writers when llmobs is enabled to accomodate apm and llmobs if (llmObsEnabled) { @@ -1648,6 +1648,45 @@ private static boolean isFeatureEnabled(AgentFeature feature) { } } + private static boolean isFeatureFlaggingEnabled() { + final Boolean providerEnabled = + featureFlaggingBooleanSetting(FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED); + final String configurationSource = + featureFlaggingSetting(FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE); + final Boolean legacyProviderEnabled = + featureFlaggingBooleanSetting(FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED); + + return !FeatureFlaggingConfig.CONFIGURATION_SOURCE_OFFLINE.equals( + FeatureFlaggingConfig.resolveConfigurationSource( + providerEnabled, configurationSource, legacyProviderEnabled)); + } + + @SuppressFBWarnings( + value = "NP_BOOLEAN_RETURN_NULL", + justification = "A null value preserves the distinction between absent and explicitly false") + private static Boolean featureFlaggingBooleanSetting(final String configKey) { + final String value = featureFlaggingSetting(configKey); + if (value == null) { + return null; + } + return Boolean.parseBoolean(value) || "1".equals(value); + } + + private static String featureFlaggingSetting(final String configKey) { + final String systemProperty = propertyNameToSystemPropertyName(configKey); + String value = SystemProperties.get(systemProperty); + if (value == null) { + value = getStableConfig(FLEET, configKey); + } + if (value == null) { + value = ddGetEnv(systemProperty); + } + if (value == null) { + value = getStableConfig(LOCAL, configKey); + } + return value; + } + /** * @see datadog.trace.api.ProductActivation#fromString(String) */ diff --git a/internal-api/src/main/java/datadog/trace/api/Config.java b/internal-api/src/main/java/datadog/trace/api/Config.java index 368e666c701..7acc7c56bb8 100644 --- a/internal-api/src/main/java/datadog/trace/api/Config.java +++ b/internal-api/src/main/java/datadog/trace/api/Config.java @@ -86,7 +86,6 @@ import static datadog.trace.api.ConfigDefaults.DEFAULT_ELASTICSEARCH_BODY_ENABLED; import static datadog.trace.api.ConfigDefaults.DEFAULT_ELASTICSEARCH_PARAMS_ENABLED; import static datadog.trace.api.ConfigDefaults.DEFAULT_EXPERIMENTATAL_JEE_SPLIT_BY_DEPLOYMENT; -import static datadog.trace.api.ConfigDefaults.DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE; import static datadog.trace.api.ConfigDefaults.DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_POLL_INTERVAL_SECONDS; import static datadog.trace.api.ConfigDefaults.DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_REQUEST_TIMEOUT_SECONDS; import static datadog.trace.api.ConfigDefaults.DEFAULT_GRPC_CLIENT_ERROR_STATUSES; @@ -726,10 +725,15 @@ import static datadog.trace.api.config.TracerConfig.WRITER_BAGGAGE_INJECT; import static datadog.trace.api.config.TracerConfig.WRITER_LINKS_INJECT; import static datadog.trace.api.config.TracerConfig.WRITER_TYPE; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_OFFLINE; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED; import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL; import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS; import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.isSupportedConfigurationSource; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.resolveConfigurationSource; import static datadog.trace.api.telemetry.LogCollector.SEND_TELEMETRY; import static datadog.trace.bootstrap.instrumentation.api.WriterConstants.OTLP_WRITER_TYPE; import static datadog.trace.util.CollectionUtils.tryMakeImmutableList; @@ -1221,6 +1225,7 @@ public static String getHostName() { private final int remoteConfigMaxExtraServices; + private final boolean featureFlaggingProviderEnabled; private final String featureFlaggingConfigurationSource; private final String featureFlaggingConfigurationSourceAgentlessBaseUrl; private final int featureFlaggingConfigurationSourcePollIntervalSeconds; @@ -2851,10 +2856,34 @@ PROFILING_DATADOG_PROFILER_ENABLED, isDatadogProfilerSafeInCurrentEnvironment()) configProvider.getInteger( REMOTE_CONFIG_MAX_EXTRA_SERVICES, DEFAULT_REMOTE_CONFIG_MAX_EXTRA_SERVICES); - featureFlaggingConfigurationSource = - normalizeFeatureFlaggingConfigurationSource( - configProvider.getString( - FEATURE_FLAGS_CONFIGURATION_SOURCE, DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE)); + final boolean configuredFeatureFlaggingProviderEnabled = + configProvider.getBoolean(FEATURE_FLAGS_ENABLED, true); + final Boolean legacyFeatureFlaggingProviderEnabled = + configProvider.getBoolean(EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED); + final String configuredFeatureFlaggingConfigurationSource = + configProvider.isSet(FEATURE_FLAGS_CONFIGURATION_SOURCE) + ? configProvider.getString(FEATURE_FLAGS_CONFIGURATION_SOURCE) + : null; + final String resolvedFeatureFlaggingConfigurationSource = + resolveConfigurationSource( + configuredFeatureFlaggingProviderEnabled, + configuredFeatureFlaggingConfigurationSource, + legacyFeatureFlaggingProviderEnabled); + if (legacyFeatureFlaggingProviderEnabled != null) { + log.warn( + "Setting {} is deprecated. Use {} and {} instead.", + EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED, + FEATURE_FLAGS_ENABLED, + FEATURE_FLAGS_CONFIGURATION_SOURCE); + } + if (!isSupportedConfigurationSource(configuredFeatureFlaggingConfigurationSource)) { + log.warn( + "Unsupported Feature Flagging configuration source: {}. Disabling Feature Flagging", + configuredFeatureFlaggingConfigurationSource); + } + featureFlaggingProviderEnabled = + !CONFIGURATION_SOURCE_OFFLINE.equals(resolvedFeatureFlaggingConfigurationSource); + featureFlaggingConfigurationSource = resolvedFeatureFlaggingConfigurationSource; featureFlaggingConfigurationSourceAgentlessBaseUrl = configProvider.getStringNotEmpty( FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL, null); @@ -3797,28 +3826,6 @@ public boolean isInferredProxyPropagationEnabled() { return traceInferredProxyEnabled; } - private static String normalizeFeatureFlaggingConfigurationSource(final String source) { - if (source == null) { - return DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE; - } - final String normalized = source.trim().toLowerCase(Locale.ROOT); - if (normalized.isEmpty()) { - return DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE; - } - switch (normalized) { - case "agentless": - case "remote_config": - case "offline": - return normalized; - default: - log.warn( - "Unsupported Feature Flagging configuration source: {}. Defaulting to {}", - source, - DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE); - return DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE; - } - } - public boolean isBaggageExtract() { return tracePropagationStylesToExtract.contains(TracePropagationStyle.BAGGAGE); } @@ -4736,6 +4743,10 @@ public int getRemoteConfigMaxExtraServices() { return remoteConfigMaxExtraServices; } + public boolean isFeatureFlaggingProviderEnabled() { + return featureFlaggingProviderEnabled; + } + public String getFeatureFlaggingConfigurationSource() { return featureFlaggingConfigurationSource; } @@ -6584,6 +6595,8 @@ public String toString() { + remoteConfigMaxPayloadSize + ", remoteConfigIntegrityCheckEnabled=" + remoteConfigIntegrityCheckEnabled + + ", featureFlaggingProviderEnabled=" + + featureFlaggingProviderEnabled + ", featureFlaggingConfigurationSource=" + featureFlaggingConfigurationSource + ", featureFlaggingConfigurationSourceAgentlessBaseUrl=" 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 7227597108b..65672636ce3 100644 --- a/internal-api/src/test/groovy/datadog/trace/api/ConfigTest.groovy +++ b/internal-api/src/test/groovy/datadog/trace/api/ConfigTest.groovy @@ -57,9 +57,11 @@ import static datadog.trace.api.config.GeneralConfig.TAGS import static datadog.trace.api.config.GeneralConfig.TRACER_METRICS_IGNORED_RESOURCES import static datadog.trace.api.config.GeneralConfig.TRACE_OTEL_SEMANTICS_ENABLED import static datadog.trace.api.config.GeneralConfig.VERSION +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_ENABLED import static datadog.trace.api.config.JmxFetchConfig.JMX_FETCH_CHECK_PERIOD import static datadog.trace.api.config.JmxFetchConfig.JMX_FETCH_ENABLED import static datadog.trace.api.config.JmxFetchConfig.JMX_FETCH_METRICS_CONFIGS @@ -3517,7 +3519,39 @@ class ConfigTest extends DDSpecification { "" | "agentless" " " | "agentless" " ReMoTe_ConFiG " | "remote_config" - "not-a-real-source" | "agentless" + "not-a-real-source" | "offline" + } + + def "feature flag configuration applies migration precedence"() { + setup: + Properties properties = new Properties() + if (providerEnabled != null) { + properties.setProperty(FEATURE_FLAGS_ENABLED, providerEnabled.toString()) + } + if (source != null) { + properties.setProperty(FEATURE_FLAGS_CONFIGURATION_SOURCE, source) + } + if (legacyProviderEnabled != null) { + properties.setProperty(EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED, legacyProviderEnabled.toString()) + } + + when: + def config = new Config(ConfigProvider.withPropertiesOverride(properties)) + + then: + config.featureFlaggingProviderEnabled == expectedEnabled + config.featureFlaggingConfigurationSource == expectedSource + + where: + providerEnabled | source | legacyProviderEnabled | expectedEnabled | expectedSource + null | null | null | true | "agentless" + null | null | true | true | "remote_config" + null | null | false | false | "offline" + null | "agentless" | true | true | "agentless" + null | "remote_config" | false | true | "remote_config" + false | "agentless" | true | false | "offline" + true | null | false | false | "offline" + null | "offline" | null | false | "offline" } def "agentless feature flag timing falls back for non-positive values"() { diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json index 74a0bb8e67e..cb163a223c9 100644 --- a/metadata/supported-configurations.json +++ b/metadata/supported-configurations.json @@ -1497,6 +1497,14 @@ "aliases": [] } ], + "DD_FEATURE_FLAGS_ENABLED": [ + { + "version": "A", + "type": "boolean", + "default": "true", + "aliases": [] + } + ], "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE": [ { "version": "A", @@ -11986,5 +11994,7 @@ } ] }, - "deprecations": {} + "deprecations": { + "DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED": "DD_FEATURE_FLAGS_ENABLED" + } } diff --git a/products/feature-flagging/feature-flagging-agent/build.gradle.kts b/products/feature-flagging/feature-flagging-agent/build.gradle.kts index 0c008e373a3..d2dc4baea2b 100644 --- a/products/feature-flagging/feature-flagging-agent/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-agent/build.gradle.kts @@ -17,6 +17,7 @@ dependencies { api(libs.slf4j) api(project(":products:feature-flagging:feature-flagging-lib")) api(project(":internal-api")) + compileOnly(project(":products:feature-flagging:feature-flagging-config")) testImplementation(libs.bundles.junit5) testImplementation(libs.bundles.mockito) 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 5c9cb20540e..5bf2ff2858d 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 @@ -1,7 +1,12 @@ 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_OFFLINE; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_REMOTE_CONFIG; + import datadog.communication.ddagent.SharedCommunicationObjects; import datadog.trace.api.Config; +import datadog.trace.api.featureflag.FeatureFlaggingGateway; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -12,16 +17,59 @@ 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() {} public static synchronized void start(final SharedCommunicationObjects sco) { - if (CONFIG_SERVICE != null || EXPOSURE_WRITER != null) { + if (STARTED) { LOGGER.debug("Feature Flagging system already started"); return; } LOGGER.debug("Feature Flagging system starting"); final Config config = Config.get(); + final String source = config.getFeatureFlaggingConfigurationSource(); + STARTED = true; + + if (CONFIGURATION_SOURCE_OFFLINE.equals(source)) { + LOGGER.debug("Feature Flagging system disabled"); + return; + } + if (CONFIGURATION_SOURCE_AGENTLESS.equals(source)) { + 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) { + STARTED = false; + throw e; + } + } + + private static void initializeSystem(final SharedCommunicationObjects sco, final Config config) { final ConfigurationSourceService configService = createConfigurationSourceService(sco, config); final ExposureWriter exposureWriter = new ExposureWriterImpl(sco, config); initialize(configService, exposureWriter); @@ -60,16 +108,15 @@ static void initialize( static ConfigurationSourceService createConfigurationSourceService( final SharedCommunicationObjects sco, final Config config) { - final ConfigurationSource configurationSource = - ConfigurationSource.from(config.getFeatureFlaggingConfigurationSource()); + final String configurationSource = config.getFeatureFlaggingConfigurationSource(); - if (configurationSource == ConfigurationSource.REMOTE_CONFIG) { + if (CONFIGURATION_SOURCE_REMOTE_CONFIG.equals(configurationSource)) { if (!config.isRemoteConfigEnabled()) { throw new IllegalStateException("Feature Flagging system started without RC"); } return new RemoteConfigServiceImpl(sco, config); } - if (configurationSource == ConfigurationSource.AGENTLESS) { + if (CONFIGURATION_SOURCE_AGENTLESS.equals(configurationSource)) { return new AgentlessConfigurationSource(config); } LOGGER.debug( @@ -78,12 +125,18 @@ static ConfigurationSourceService createConfigurationSourceService( } 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(); @@ -102,25 +155,7 @@ public static synchronized void stop() { LOGGER.debug("Feature Flagging system stopped"); } - private enum ConfigurationSource { - AGENTLESS("agentless"), - REMOTE_CONFIG("remote_config"), - OFFLINE("offline"); - - private final String value; - - ConfigurationSource(final String value) { - this.value = value; - } - - private static ConfigurationSource from(final String value) { - for (final ConfigurationSource source : values()) { - if (source.value.equals(value)) { - return source; - } - } - throw new IllegalArgumentException( - "Unsupported Feature Flagging configuration source: " + value); - } + static boolean isAwaitingApplicationActivation() { + return ACTIVATION_LISTENER != null; } } 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 523736d13db..2f5c72bb8ab 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 @@ -2,10 +2,13 @@ import static datadog.trace.api.config.RemoteConfigConfig.REMOTE_CONFIGURATION_ENABLED; import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; 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.doThrow; @@ -20,6 +23,7 @@ import datadog.remoteconfig.ConfigurationPoller; import datadog.remoteconfig.Product; import datadog.trace.api.Config; +import datadog.trace.api.featureflag.FeatureFlaggingGateway; import datadog.trace.junit.utils.config.WithConfig; import okhttp3.HttpUrl; import okhttp3.OkHttpClient; @@ -27,6 +31,25 @@ class FeatureFlaggingSystemTest { + @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 agentlessStartWaitsForApplicationProviderActivation() { + try { + FeatureFlaggingSystem.start(sharedCommunicationObjects()); + + assertTrue(FeatureFlaggingSystem.isAwaitingApplicationActivation()); + FeatureFlaggingGateway.activate(); + assertFalse(FeatureFlaggingSystem.isAwaitingApplicationActivation()); + } finally { + FeatureFlaggingSystem.stop(); + } + + assertFalse(FeatureFlaggingSystem.isAwaitingApplicationActivation()); + } + @Test @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "remote_config") @WithConfig(key = REMOTE_CONFIGURATION_ENABLED, value = "true") @@ -97,9 +120,8 @@ void explicitRemoteConfigUsesRemoteConfigService() { @Test @WithConfig(key = FEATURE_FLAGS_CONFIGURATION_SOURCE, value = "invalid") - void invalidConfigurationSourceUsesAgentlessDefault() { - assertInstanceOf( - AgentlessConfigurationSource.class, + void invalidConfigurationSourceFailsClosed() { + assertNull( FeatureFlaggingSystem.createConfigurationSourceService( sharedCommunicationObjects(), Config.get())); } @@ -109,11 +131,9 @@ void rejectsUnsupportedNormalizedConfigurationSource() { Config config = mock(Config.class); when(config.getFeatureFlaggingConfigurationSource()).thenReturn("invalid"); - assertThrows( - IllegalArgumentException.class, - () -> - FeatureFlaggingSystem.createConfigurationSourceService( - sharedCommunicationObjects(), config)); + assertNull( + FeatureFlaggingSystem.createConfigurationSourceService( + sharedCommunicationObjects(), config)); } @Test 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 654fb6e8f6c..6d55c54a5b3 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 @@ -67,6 +67,7 @@ public DDEvaluator(final Runnable configCallback) { @Override public boolean initialize( final long timeout, final TimeUnit unit, final EvaluationContext context) throws Exception { + FeatureFlaggingGateway.activate(); FeatureFlaggingGateway.addConfigListener(this); return initializationLatch.await(timeout, unit) || hasConfiguration(); } 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 3421fe3454e..cac510732db 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 @@ -23,6 +23,7 @@ import com.squareup.moshi.JsonWriter; import com.squareup.moshi.Moshi; import com.squareup.moshi.Types; +import datadog.trace.api.featureflag.FeatureFlaggingGateway; import datadog.trace.api.featureflag.ufc.v1.Flag; import datadog.trace.api.featureflag.ufc.v1.ServerConfiguration; import dev.openfeature.sdk.ErrorCode; @@ -145,6 +146,22 @@ public void testInitializeTimesOutWithoutConfig() throws Exception { } } + @Test + public void testInitializeSignalsApplicationProviderActivation() throws Exception { + final FeatureFlaggingGateway.ActivationListener listener = + mock(FeatureFlaggingGateway.ActivationListener.class); + final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); + FeatureFlaggingGateway.addActivationListener(listener); + try { + evaluator.initialize(1, MILLISECONDS, mock(EvaluationContext.class)); + + verify(listener).activate(); + } finally { + evaluator.shutdown(); + FeatureFlaggingGateway.removeActivationListener(listener); + } + } + @Test public void testInitializeWaitsForNonNullConfig() throws Exception { final DDEvaluator evaluator = new DDEvaluator(mock(Runnable.class)); 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 2704b4be341..c8f5625c855 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 @@ -11,11 +11,16 @@ public abstract class FeatureFlaggingGateway { public interface ConfigListener extends Consumer {} + public interface ActivationListener { + void activate(); + } + public interface ExposureListener extends Consumer {} public interface SpanEnrichmentListener extends Consumer {} private static final List CONFIG_LISTENERS = new CopyOnWriteArrayList<>(); + private static final List ACTIVATION_LISTENERS = new CopyOnWriteArrayList<>(); private static final List EXPOSURE_LISTENERS = new CopyOnWriteArrayList<>(); private static final List SPAN_ENRICHMENT_LISTENERS = new CopyOnWriteArrayList<>(); @@ -42,6 +47,19 @@ public static void dispatch(final ServerConfiguration config) { CONFIG_LISTENERS.forEach(listener -> listener.accept(config)); } + public static void addActivationListener(final ActivationListener listener) { + ACTIVATION_LISTENERS.add(listener); + } + + public static void removeActivationListener(final ActivationListener listener) { + ACTIVATION_LISTENERS.remove(listener); + } + + /** Signals that application code initialized the Datadog OpenFeature provider. */ + public static void activate() { + ACTIVATION_LISTENERS.forEach(ActivationListener::activate); + } + 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 3af2ed5add8..daaaf8d7001 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 @@ -13,6 +13,7 @@ class FeatureFlaggingGatewayTest { private FeatureFlaggingGateway.ConfigListener configListener; + private FeatureFlaggingGateway.ActivationListener activationListener; private FeatureFlaggingGateway.ExposureListener exposureListener; private FeatureFlaggingGateway.SpanEnrichmentListener spanEnrichmentListener; private ServerConfiguration firstConfiguration; @@ -23,6 +24,7 @@ class FeatureFlaggingGatewayTest { @BeforeEach void setUp() { configListener = mock(FeatureFlaggingGateway.ConfigListener.class); + activationListener = mock(FeatureFlaggingGateway.ActivationListener.class); exposureListener = mock(FeatureFlaggingGateway.ExposureListener.class); spanEnrichmentListener = mock(FeatureFlaggingGateway.SpanEnrichmentListener.class); firstConfiguration = mock(ServerConfiguration.class); @@ -34,10 +36,21 @@ void setUp() { @AfterEach void tearDown() { FeatureFlaggingGateway.removeConfigListener(configListener); + FeatureFlaggingGateway.removeActivationListener(activationListener); FeatureFlaggingGateway.removeExposureListener(exposureListener); FeatureFlaggingGateway.removeSpanEnrichmentListener(spanEnrichmentListener); } + @Test + void testProviderActivationListener() { + FeatureFlaggingGateway.addActivationListener(activationListener); + + FeatureFlaggingGateway.activate(); + + verify(activationListener).activate(); + verifyNoMoreInteractions(activationListener); + } + @Test void testAttachingAConfigListener() { clearCurrentServerConfiguration(); diff --git a/products/feature-flagging/feature-flagging-config/build.gradle.kts b/products/feature-flagging/feature-flagging-config/build.gradle.kts index 14109d8dfd9..97dd3aa9f0d 100644 --- a/products/feature-flagging/feature-flagging-config/build.gradle.kts +++ b/products/feature-flagging/feature-flagging-config/build.gradle.kts @@ -4,9 +4,8 @@ plugins { apply(from = "$rootDir/gradle/java.gradle") -description = "Feature flagging configuration keys (compile-time constants)" +description = "Feature flagging configuration keys and source resolution" -extra["excludedClassesCoverage"] = listOf( - // Constants-only holder — no executable logic to cover. - "datadog.trace.api.featureflag.config.FeatureFlaggingConfig", -) +dependencies { + testImplementation(libs.bundles.junit5) +} 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 379f6470c5b..88079188cf3 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 @@ -2,11 +2,17 @@ public class FeatureFlaggingConfig { - public static final String FLAGGING_PROVIDER_ENABLED = "experimental.flagging.provider.enabled"; + public static final String CONFIGURATION_SOURCE_AGENTLESS = "agentless"; + public static final String CONFIGURATION_SOURCE_OFFLINE = "offline"; + public static final String CONFIGURATION_SOURCE_REMOTE_CONFIG = "remote_config"; + + public static final String FEATURE_FLAGS_ENABLED = "feature.flags.enabled"; + public static final String EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED = + "experimental.flagging.provider.enabled"; /** * Opt-in gate for APM span enrichment with feature-flag evaluation metadata. DISTINCT from {@link - * #FLAGGING_PROVIDER_ENABLED} and OFF by default — enabling the provider does not enable span + * #FEATURE_FLAGS_ENABLED} and OFF by default — enabling the provider does not enable span * enrichment. */ public static final String EXPERIMENTAL_SPAN_ENRICHMENT_ENABLED = @@ -21,5 +27,40 @@ public class FeatureFlaggingConfig { public static final String FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS = "feature.flags.configuration.source.agentless.request.timeout.seconds"; + public static String resolveConfigurationSource( + final Boolean providerEnabled, + final String explicitSource, + final Boolean legacyProviderEnabled) { + if (Boolean.FALSE.equals(providerEnabled)) { + return CONFIGURATION_SOURCE_OFFLINE; + } + if (explicitSource != null && !explicitSource.trim().isEmpty()) { + final String source = explicitSource.trim(); + if (CONFIGURATION_SOURCE_AGENTLESS.equalsIgnoreCase(source)) { + return CONFIGURATION_SOURCE_AGENTLESS; + } + if (CONFIGURATION_SOURCE_REMOTE_CONFIG.equalsIgnoreCase(source)) { + return CONFIGURATION_SOURCE_REMOTE_CONFIG; + } + return CONFIGURATION_SOURCE_OFFLINE; + } + if (legacyProviderEnabled != null) { + return legacyProviderEnabled + ? CONFIGURATION_SOURCE_REMOTE_CONFIG + : CONFIGURATION_SOURCE_OFFLINE; + } + return CONFIGURATION_SOURCE_AGENTLESS; + } + + public static boolean isSupportedConfigurationSource(final String source) { + if (source == null || source.trim().isEmpty()) { + return true; + } + final String normalized = source.trim(); + return CONFIGURATION_SOURCE_AGENTLESS.equalsIgnoreCase(normalized) + || CONFIGURATION_SOURCE_REMOTE_CONFIG.equalsIgnoreCase(normalized) + || CONFIGURATION_SOURCE_OFFLINE.equalsIgnoreCase(normalized); + } + private FeatureFlaggingConfig() {} } 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 new file mode 100644 index 00000000000..74d45fe7eed --- /dev/null +++ b/products/feature-flagging/feature-flagging-config/src/test/java/datadog/trace/api/featureflag/config/FeatureFlaggingConfigTest.java @@ -0,0 +1,42 @@ +package datadog.trace.api.featureflag.config; + +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_AGENTLESS; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_OFFLINE; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.CONFIGURATION_SOURCE_REMOTE_CONFIG; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.isSupportedConfigurationSource; +import static datadog.trace.api.featureflag.config.FeatureFlaggingConfig.resolveConfigurationSource; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class FeatureFlaggingConfigTest { + + @Test + void appliesConfigurationPrecedence() { + assertEquals(CONFIGURATION_SOURCE_AGENTLESS, resolveConfigurationSource(null, null, null)); + assertEquals(CONFIGURATION_SOURCE_AGENTLESS, resolveConfigurationSource(null, " ", null)); + assertEquals(CONFIGURATION_SOURCE_REMOTE_CONFIG, resolveConfigurationSource(null, null, true)); + assertEquals(CONFIGURATION_SOURCE_OFFLINE, resolveConfigurationSource(null, null, false)); + assertEquals( + CONFIGURATION_SOURCE_AGENTLESS, resolveConfigurationSource(null, "agentless", true)); + assertEquals( + CONFIGURATION_SOURCE_REMOTE_CONFIG, + resolveConfigurationSource(null, " remote_CONFIG ", false)); + assertEquals( + CONFIGURATION_SOURCE_OFFLINE, resolveConfigurationSource(false, "agentless", true)); + assertEquals(CONFIGURATION_SOURCE_OFFLINE, resolveConfigurationSource(null, "invalid", null)); + assertEquals(CONFIGURATION_SOURCE_OFFLINE, resolveConfigurationSource(null, "offline", null)); + } + + @Test + void recognizesSupportedExplicitSources() { + assertTrue(isSupportedConfigurationSource(null)); + assertTrue(isSupportedConfigurationSource(" ")); + assertTrue(isSupportedConfigurationSource("agentless")); + assertTrue(isSupportedConfigurationSource(" REMOTE_CONFIG ")); + assertTrue(isSupportedConfigurationSource("OFFLINE")); + assertFalse(isSupportedConfigurationSource("invalid")); + } +}