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 4d9e7b10338..e9db18f9eb4 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 @@ -33,6 +33,7 @@ import datadog.trace.api.profiling.ProfilingEnablement; import datadog.trace.api.scopemanager.ScopeListener; import datadog.trace.bootstrap.benchmark.StaticEventLogger; +import datadog.trace.bootstrap.config.provider.StableConfigSource; import datadog.trace.bootstrap.instrumentation.api.AgentTracer; import datadog.trace.bootstrap.instrumentation.api.AgentTracer.TracerAPI; import datadog.trace.bootstrap.instrumentation.api.ProfilingContextIntegration; @@ -1214,9 +1215,15 @@ private static boolean isFeatureEnabled(AgentFeature feature) { // must be kept in sync with logic from Config! final String featureEnabledSysprop = feature.getSystemProp(); String featureEnabled = System.getProperty(featureEnabledSysprop); + if (featureEnabled == null) { + featureEnabled = getStableConfig(StableConfigSource.MANAGED, featureEnabledSysprop); + } if (featureEnabled == null) { featureEnabled = ddGetEnv(featureEnabledSysprop); } + if (featureEnabled == null) { + featureEnabled = getStableConfig(StableConfigSource.USER, featureEnabledSysprop); + } if (feature.isEnabledByDefault()) { // true unless it's explicitly set to "false" @@ -1353,6 +1360,11 @@ private static String ddGetProperty(final String sysProp) { return value; } + /** Looks for sysProp in the Stable Configuration input */ + private static String getStableConfig(StableConfigSource source, final String sysProp) { + return source.get(sysProp); + } + /** Looks for the "DD_" environment variable equivalent of the given "dd." system property. */ private static String ddGetEnv(final String sysProp) { return System.getenv(toEnvVar(sysProp)); diff --git a/dd-java-agent/instrumentation/graal/native-image/src/main/java/datadog/trace/instrumentation/graal/nativeimage/NativeImageGeneratorRunnerInstrumentation.java b/dd-java-agent/instrumentation/graal/native-image/src/main/java/datadog/trace/instrumentation/graal/nativeimage/NativeImageGeneratorRunnerInstrumentation.java index 3994ff991b5..eea77052645 100644 --- a/dd-java-agent/instrumentation/graal/native-image/src/main/java/datadog/trace/instrumentation/graal/nativeimage/NativeImageGeneratorRunnerInstrumentation.java +++ b/dd-java-agent/instrumentation/graal/native-image/src/main/java/datadog/trace/instrumentation/graal/nativeimage/NativeImageGeneratorRunnerInstrumentation.java @@ -78,6 +78,7 @@ public static void onEnter(@Advice.Argument(value = 0, readOnly = false) String[ + "datadog.trace.api.env.CapturedEnvironment:build_time," + "datadog.trace.api.ConfigCollector:rerun," + "datadog.trace.api.ConfigDefaults:build_time," + + "datadog.trace.api.ConfigOrigin:build_time," + "datadog.trace.api.ConfigSetting:build_time," + "datadog.trace.api.EventTracker:build_time," + "datadog.trace.api.InstrumenterConfig:build_time," @@ -106,6 +107,8 @@ public static void onEnter(@Advice.Argument(value = 0, readOnly = false) String[ + "datadog.trace.bootstrap.config.provider.EnvironmentConfigSource:build_time," + "datadog.trace.bootstrap.config.provider.OtelEnvironmentConfigSource:build_time," + "datadog.trace.bootstrap.config.provider.SystemPropertiesConfigSource:build_time," + + "datadog.trace.bootstrap.config.provider.StableConfigSource:build_time," + + "datadog.trace.bootstrap.config.provider.StableConfigSource$StableConfig:build_time," + "datadog.trace.bootstrap.Agent:build_time," + "datadog.trace.bootstrap.BootstrapProxy:build_time," + "datadog.trace.bootstrap.CallDepthThreadLocalMap:build_time," diff --git a/internal-api/build.gradle b/internal-api/build.gradle index 164267028cd..7cf7b472759 100644 --- a/internal-api/build.gradle +++ b/internal-api/build.gradle @@ -235,6 +235,7 @@ dependencies { testImplementation libs.commons.math testImplementation libs.bundles.mockito testImplementation libs.truth + testImplementation 'org.yaml:snakeyaml:2.0' } jmh { diff --git a/internal-api/src/main/java/datadog/trace/api/ConfigOrigin.java b/internal-api/src/main/java/datadog/trace/api/ConfigOrigin.java index 3f46985758a..78cf4e20e2b 100644 --- a/internal-api/src/main/java/datadog/trace/api/ConfigOrigin.java +++ b/internal-api/src/main/java/datadog/trace/api/ConfigOrigin.java @@ -7,6 +7,10 @@ public enum ConfigOrigin { REMOTE("remote_config"), /** configurations that are set through JVM properties */ JVM_PROP("jvm_prop"), + /** configuration read in the stable config file, managed by users */ + USER_STABLE_CONFIG("user_stable_config"), + /** configuration read in the stable config file, managed by fleet */ + MANAGED_STABLE_CONFIG("managed_stable_config"), /** set when the user has not set any configuration for the key (defaults to a value) */ DEFAULT("default"); diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/config/provider/ConfigProvider.java b/internal-api/src/main/java/datadog/trace/bootstrap/config/provider/ConfigProvider.java index d6418a56fef..60acd7330ae 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/config/provider/ConfigProvider.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/config/provider/ConfigProvider.java @@ -356,15 +356,19 @@ public static ConfigProvider createDefault() { if (configProperties.isEmpty()) { return new ConfigProvider( new SystemPropertiesConfigSource(), + StableConfigSource.MANAGED, new EnvironmentConfigSource(), new OtelEnvironmentConfigSource(), + StableConfigSource.USER, new CapturedEnvironmentConfigSource()); } else { return new ConfigProvider( new SystemPropertiesConfigSource(), + StableConfigSource.MANAGED, new EnvironmentConfigSource(), new PropertiesConfigSource(configProperties, true), new OtelEnvironmentConfigSource(configProperties), + StableConfigSource.USER, new CapturedEnvironmentConfigSource()); } } @@ -378,16 +382,20 @@ public static ConfigProvider withoutCollector() { return new ConfigProvider( false, new SystemPropertiesConfigSource(), + StableConfigSource.MANAGED, new EnvironmentConfigSource(), new OtelEnvironmentConfigSource(), + StableConfigSource.USER, new CapturedEnvironmentConfigSource()); } else { return new ConfigProvider( false, new SystemPropertiesConfigSource(), + StableConfigSource.MANAGED, new EnvironmentConfigSource(), new PropertiesConfigSource(configProperties, true), new OtelEnvironmentConfigSource(configProperties), + StableConfigSource.USER, new CapturedEnvironmentConfigSource()); } } @@ -403,17 +411,21 @@ public static ConfigProvider withPropertiesOverride(Properties properties) { if (configProperties.isEmpty()) { return new ConfigProvider( new SystemPropertiesConfigSource(), + StableConfigSource.MANAGED, new EnvironmentConfigSource(), providedConfigSource, new OtelEnvironmentConfigSource(), + StableConfigSource.USER, new CapturedEnvironmentConfigSource()); } else { return new ConfigProvider( providedConfigSource, new SystemPropertiesConfigSource(), + StableConfigSource.MANAGED, new EnvironmentConfigSource(), new PropertiesConfigSource(configProperties, true), new OtelEnvironmentConfigSource(configProperties), + StableConfigSource.USER, new CapturedEnvironmentConfigSource()); } } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/config/provider/StableConfigParser.java b/internal-api/src/main/java/datadog/trace/bootstrap/config/provider/StableConfigParser.java new file mode 100644 index 00000000000..bb651e11588 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/bootstrap/config/provider/StableConfigParser.java @@ -0,0 +1,77 @@ +package datadog.trace.bootstrap.config.provider; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class StableConfigParser { + private static final Logger log = LoggerFactory.getLogger(StableConfigParser.class); + // Match config_id: + private static final Pattern idPattern = Pattern.compile("^config_id\\s*:(.*)$"); + // Match 'apm_configuration_default:' + private static final Pattern apmConfigPattern = Pattern.compile("^apm_configuration_default:$"); + // Match indented (2 spaces) key-value pairs, either with double quotes or without + private static final Pattern keyValPattern = + Pattern.compile("^\\s{2}([^:]+):\\s*(\"[^\"]*\"|[^\"\\n]*)$");; + + public static StableConfigSource.StableConfig parse(String filePath) throws IOException { + File file = new File(filePath); + if (!file.exists()) { + log.debug("Stable configuration file not available at specified path: {}", file); + return StableConfigSource.StableConfig.EMPTY; + } + Map configMap = new HashMap<>(); + String[] configId = new String[1]; + try (Stream lines = Files.lines(Paths.get(filePath))) { + int apmConfigNotFound = -1, apmConfigStarted = 0, apmConfigComplete = 1; + int[] apmConfigFound = {apmConfigNotFound}; + lines.forEach( + line -> { + Matcher matcher = idPattern.matcher(line); + if (matcher.find()) { + // Do not allow duplicate config_id keys + if (configId[0] != null) { + throw new RuntimeException("Duplicate config_id keys found; file may be malformed"); + } + configId[0] = trimQuotes(matcher.group(1).trim()); + return; // go to next line + } + // TODO: Do not allow duplicate apm_configuration_default keys; and/or return early once + // apmConfigFound[0] == apmConfigComplete + if (apmConfigFound[0] == apmConfigNotFound + && apmConfigPattern.matcher(line).matches()) { + apmConfigFound[0] = apmConfigStarted; + return; // go to next line + } + if (apmConfigFound[0] == apmConfigStarted) { + Matcher keyValueMatcher = keyValPattern.matcher(line); + if (keyValueMatcher.matches()) { + configMap.put( + keyValueMatcher.group(1).trim(), + trimQuotes(keyValueMatcher.group(2).trim())); // Store key-value pair in map + } else { + // If we encounter a non-indented or non-key-value line, stop processing + apmConfigFound[0] = apmConfigComplete; + } + } + }); + return new StableConfigSource.StableConfig(configId[0], configMap); + } + } + + private static String trimQuotes(String value) { + if (value.length() > 1 && (value.startsWith("'") && value.endsWith("'")) + || (value.startsWith("\"") && value.endsWith("\""))) { + return value.substring(1, value.length() - 1); + } + return value; + } +} diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/config/provider/StableConfigSource.java b/internal-api/src/main/java/datadog/trace/bootstrap/config/provider/StableConfigSource.java new file mode 100644 index 00000000000..f95dd71d33d --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/bootstrap/config/provider/StableConfigSource.java @@ -0,0 +1,84 @@ +package datadog.trace.bootstrap.config.provider; + +import static datadog.trace.util.Strings.propertyNameToEnvironmentVariableName; + +import datadog.trace.api.ConfigOrigin; +import java.util.Collections; +import java.util.Map; +import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public final class StableConfigSource extends ConfigProvider.Source { + private static final Logger log = LoggerFactory.getLogger(StableConfigSource.class); + + public static final String USER_STABLE_CONFIG_PATH = + "/etc/datadog-agent/application_monitoring.yaml"; + public static final String MANAGED_STABLE_CONFIG_PATH = + "/etc/datadog-agent/managed/datadog-agent/stable/application_monitoring.yaml"; + public static final StableConfigSource USER = + new StableConfigSource(USER_STABLE_CONFIG_PATH, ConfigOrigin.USER_STABLE_CONFIG); + public static final StableConfigSource MANAGED = + new StableConfigSource( + StableConfigSource.MANAGED_STABLE_CONFIG_PATH, ConfigOrigin.MANAGED_STABLE_CONFIG); + + private final ConfigOrigin fileOrigin; + + private final StableConfig config; + + StableConfigSource(String file, ConfigOrigin origin) { + this.fileOrigin = origin; + StableConfig cfg; + try { + cfg = StableConfigParser.parse(file); + } catch (Throwable e) { + log.debug("Stable configuration file not readable at specified path: {}", file); + cfg = StableConfig.EMPTY; + } + this.config = cfg; + } + + @Override + public String get(String key) { + if (this.config == StableConfig.EMPTY) { + return null; + } + return this.config.get(propertyNameToEnvironmentVariableName(key)); + } + + @Override + public ConfigOrigin origin() { + return fileOrigin; + } + + public Set getKeys() { + return this.config.getKeys(); + } + + public String getConfigId() { + return this.config.getConfigId(); + } + + public static class StableConfig { + public static final StableConfig EMPTY = new StableConfig(null, Collections.emptyMap()); + private final Map apmConfiguration; + private final String configId; + + StableConfig(String configId, Map configMap) { + this.configId = configId; + this.apmConfiguration = configMap; + } + + public String get(String key) { + return this.apmConfiguration.get(key); + } + + public Set getKeys() { + return this.apmConfiguration.keySet(); + } + + public String getConfigId() { + return this.configId; + } + } +} diff --git a/internal-api/src/test/groovy/datadog/trace/bootstrap/config/provider/StableConfigParserTest.groovy b/internal-api/src/test/groovy/datadog/trace/bootstrap/config/provider/StableConfigParserTest.groovy new file mode 100644 index 00000000000..f7cc3a0101c --- /dev/null +++ b/internal-api/src/test/groovy/datadog/trace/bootstrap/config/provider/StableConfigParserTest.groovy @@ -0,0 +1,127 @@ +package datadog.trace.bootstrap.config.provider + +import datadog.trace.test.util.DDSpecification + +import java.nio.file.Files +import java.nio.file.Path + +class StableConfigParserTest extends DDSpecification { + + def "test parser"() { + when: + Path filePath = StableConfigSourceTest.tempFile() + if (filePath == null) { + throw new AssertionError("Failed to create test file") + } + String yaml = """ +something-irrelevant: "" +config_id: 12345 +something : not : expected << and weird format + inufjka << + [a, + b, + c, + d] +apm_configuration_default: + KEY_ONE: value_one + KEY_TWO: "value_two" + KEY_THREE: 100 + KEY_FOUR: true + KEY_FIVE: [a,b,c,d] +something-else-irrelevant: value-irrelevant +""" + try { + StableConfigSourceTest.writeFileRaw(filePath, yaml) + } catch (IOException e) { + throw new AssertionError("Failed to write to file: ${e.message}") + } + + StableConfigSource.StableConfig cfg + try { + cfg = StableConfigParser.parse(filePath.toString()) + } catch (Exception e) { + throw new AssertionError("Failed to parse the file: ${e.message}") + } + + then: + def keys = cfg.getKeys() + keys.size() == 5 + !keys.contains("something-irrelevant") + !keys.contains("something-else-irrelevant") + cfg.getConfigId().trim() == ("12345") + cfg.get("KEY_ONE") == "value_one" + cfg.get("KEY_TWO") == "value_two" + cfg.get("KEY_THREE") == "100" + cfg.get("KEY_FOUR") == "true" + cfg.get("KEY_FIVE") == "[a,b,c,d]" + Files.delete(filePath) + } + + def "test duplicate config_id"() { + when: + Path filePath = StableConfigSourceTest.tempFile() + if (filePath == null) { + throw new AssertionError("Failed to create test file") + } + String yaml = """ +config_id: 12345 +something-irrelevant: "" +apm_configuration_default: + DD_KEY: value +config_id: 67890 +""" + + try { + StableConfigSourceTest.writeFileRaw(filePath, yaml) + } catch (IOException e) { + throw new AssertionError("Failed to write to file: ${e.message}") + } + + Exception exception + StableConfigSource.StableConfig cfg + try { + cfg = StableConfigParser.parse(filePath.toString()) + } catch (Exception e) { + exception = e + } + + then: + cfg == null + exception != null + exception.getMessage() == "Duplicate config_id keys found; file may be malformed" + } + + def "test duplicate apm_configuration_default"() { + // Assert that only the first entry is used + when: + Path filePath = StableConfigSourceTest.tempFile() + if (filePath == null) { + throw new AssertionError("Failed to create test file") + } + String yaml = """ +apm_configuration_default: + KEY_1: value_1 +something-else-irrelevant: value-irrelevant +apm_configuration_default: + KEY_2: value_2 +""" + try { + StableConfigSourceTest.writeFileRaw(filePath, yaml) + } catch (IOException e) { + throw new AssertionError("Failed to write to file: ${e.message}") + } + + StableConfigSource.StableConfig cfg + try { + cfg = StableConfigParser.parse(filePath.toString()) + } catch (Exception e) { + throw new AssertionError("Failed to parse the file: ${e.message}") + } + + then: + def keys = cfg.getKeys() + keys.size() == 1 + !keys.contains("KEY_2") + cfg.get("KEY_1") == "value_1" + } +} diff --git a/internal-api/src/test/groovy/datadog/trace/bootstrap/config/provider/StableConfigSourceTest.groovy b/internal-api/src/test/groovy/datadog/trace/bootstrap/config/provider/StableConfigSourceTest.groovy new file mode 100644 index 00000000000..ad0bbb252b9 --- /dev/null +++ b/internal-api/src/test/groovy/datadog/trace/bootstrap/config/provider/StableConfigSourceTest.groovy @@ -0,0 +1,170 @@ +package datadog.trace.bootstrap.config.provider + +import datadog.trace.api.ConfigOrigin +import datadog.trace.test.util.DDSpecification +import org.yaml.snakeyaml.DumperOptions +import org.yaml.snakeyaml.Yaml +import spock.lang.Shared + +import java.nio.file.Path +import java.nio.file.Files +import java.nio.file.StandardOpenOption + +class StableConfigSourceTest extends DDSpecification { + + def "test file doesn't exist"() { + setup: + StableConfigSource config = new StableConfigSource(StableConfigSource.USER_STABLE_CONFIG_PATH, ConfigOrigin.USER_STABLE_CONFIG) + + expect: + config.getKeys().size() == 0 + config.getConfigId() == null + } + + def "test empty file"() { + when: + Path filePath = tempFile() + if (filePath == null) { + throw new AssertionError("Failed to create test file") + } + StableConfigSource config = new StableConfigSource(filePath.toString(), ConfigOrigin.USER_STABLE_CONFIG) + + then: + config.getKeys().size() == 0 + config.getConfigId() == null + } + + def "test get"() { + when: + Path filePath = tempFile() + if (filePath == null) { + throw new AssertionError("Failed to create test file") + } + + def configs = new HashMap<>() << ["DD_SERVICE": "svc", "DD_ENV": "env", "CONFIG_NO_DD": "value123"] + + try { + writeFileYaml(filePath, "12345", configs) + } catch (IOException e) { + throw new AssertionError("Failed to write to file: ${e.message}") + } + + StableConfigSource cfg = new StableConfigSource(filePath.toString(), ConfigOrigin.USER_STABLE_CONFIG) + + then: + cfg.get("service") == "svc" + cfg.get("env") == "env" + cfg.get("config_no_dd") == null + cfg.get("config_nonexistent") == null + cfg.getKeys().size() == 3 + cfg.getConfigId() == "12345" + Files.delete(filePath) + } + + def "test file invalid format"() { + when: + Path filePath = tempFile() + if (filePath == null) { + throw new AssertionError("Failed to create test file") + } + + try { + writeFileRaw(filePath, configId, configs) + } catch (IOException e) { + throw new AssertionError("Failed to write to file: ${e.message}") + } + + StableConfigSource stableCfg = new StableConfigSource(filePath.toString(), ConfigOrigin.USER_STABLE_CONFIG) + + then: + stableCfg.getConfigId() == null + stableCfg.getKeys().size() == 0 + Files.delete(filePath) + + where: + configId | configs + null | corruptYaml + "12345" | "this is not yaml format!" + } + + def "test file valid format"() { + when: + Path filePath = tempFile() + if (filePath == null) { + throw new AssertionError("Failed to create test file") + } + + try { + writeFileYaml(filePath, configId, configs) + } catch (IOException e) { + throw new AssertionError("Failed to write to file: ${e.message}") + } + + StableConfigSource stableCfg = new StableConfigSource(filePath.toString(), ConfigOrigin.USER_STABLE_CONFIG) + + then: + for (key in configs.keySet()) { + String keyString = (String) key + keyString = keyString.substring(4) // Cut `DD_` + stableCfg.get(keyString) == configs.get(key) + } + Files.delete(filePath) + + where: + configId | configs + "" | new HashMap<>() + "12345" | new HashMap<>() << ["DD_KEY_ONE": "one", "DD_KEY_TWO": "two"] + } + + // Corrupt YAML string variable used for testing, defined outside the 'where' block for readability + @Shared + def corruptYaml = ''' + abc: 123 + def: + ghi: "jkl" + lmn: 456 + ''' + + static Path tempFile() { + try { + return Files.createTempFile("testFile_", ".yaml") + } catch (IOException e) { + println "Error creating file: ${e.message}" + e.printStackTrace() + return null // or throw new RuntimeException("File creation failed", e) + } + } + + static writeFileYaml(Path filePath, String configId, Map configs) { + DumperOptions options = new DumperOptions() + options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK) + + // Prepare to write the data map to the file in yaml format + Yaml yaml = new Yaml(options) + String yamlString + Map data = new HashMap<>() + if (configId != null) { + data.put("config_id", configId) + } + if (configs instanceof HashMap) { + data.put("apm_configuration_default", configs) + } + + yamlString = yaml.dump(data) + + StandardOpenOption[] openOpts = [StandardOpenOption.WRITE] as StandardOpenOption[] + Files.write(filePath, yamlString.getBytes(), openOpts) + } + + // Use this if you want to explicitly write/test configId + def writeFileRaw(Path filePath, String configId, String configs) { + String data = configId + "\n" + configs + StandardOpenOption[] openOpts = [StandardOpenOption.WRITE] as StandardOpenOption[] + Files.write(filePath, data.getBytes(), openOpts) + } + + static writeFileRaw(Path filePath, String data) { + StandardOpenOption[] openOpts = [StandardOpenOption.WRITE] as StandardOpenOption[] + Files.write(filePath, data.getBytes(), openOpts) + } +}