diff --git a/dd-trace-core/src/main/java/datadog/trace/core/TracingConfigPoller.java b/dd-trace-core/src/main/java/datadog/trace/core/TracingConfigPoller.java index ad3a57c1505..94eb01b7694 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/TracingConfigPoller.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/TracingConfigPoller.java @@ -8,6 +8,7 @@ import static datadog.remoteconfig.Capabilities.CAPABILITY_APM_TRACING_ENABLE_DYNAMIC_INSTRUMENTATION; import static datadog.remoteconfig.Capabilities.CAPABILITY_APM_TRACING_ENABLE_EXCEPTION_REPLAY; import static datadog.remoteconfig.Capabilities.CAPABILITY_APM_TRACING_ENABLE_LIVE_DEBUGGING; +import static datadog.remoteconfig.Capabilities.CAPABILITY_APM_TRACING_MULTICONFIG; import static datadog.remoteconfig.Capabilities.CAPABILITY_APM_TRACING_SAMPLE_RATE; import static datadog.remoteconfig.Capabilities.CAPABILITY_APM_TRACING_SAMPLE_RULES; import static datadog.remoteconfig.Capabilities.CAPABILITY_APM_TRACING_TRACING_ENABLED; @@ -34,6 +35,7 @@ import java.io.ByteArrayInputStream; import java.io.IOException; import java.util.Collections; +import java.util.Comparator; import java.util.HashMap; import java.util.Iterator; import java.util.List; @@ -74,7 +76,8 @@ public void start(Config config, SharedCommunicationObjects sco) { | CAPABILITY_APM_TRACING_ENABLE_DYNAMIC_INSTRUMENTATION | CAPABILITY_APM_TRACING_ENABLE_EXCEPTION_REPLAY | CAPABILITY_APM_TRACING_ENABLE_CODE_ORIGIN - | CAPABILITY_APM_TRACING_ENABLE_LIVE_DEBUGGING); + | CAPABILITY_APM_TRACING_ENABLE_LIVE_DEBUGGING + | CAPABILITY_APM_TRACING_MULTICONFIG); } stopPolling = new Updater().register(config, configPoller); } @@ -87,14 +90,17 @@ public void stop() { final class Updater implements ProductListener { private final JsonAdapter CONFIG_OVERRIDES_ADAPTER; + private final JsonAdapter LIB_CONFIG_ADAPTER; private final JsonAdapter TRACE_SAMPLING_RULE; { Moshi MOSHI = new Moshi.Builder().add(new TracingSamplingRulesAdapter()).build(); CONFIG_OVERRIDES_ADAPTER = MOSHI.adapter(ConfigOverrides.class); + LIB_CONFIG_ADAPTER = MOSHI.adapter(LibConfig.class); TRACE_SAMPLING_RULE = MOSHI.adapter(TracingSamplingRule.class); } + private final Map configs = new HashMap<>(); private boolean receivedOverrides = false; public Runnable register(Config config, ConfigurationPoller poller) { @@ -115,11 +121,12 @@ public void accept(ConfigKey configKey, byte[] content, PollingRateHinter hinter Okio.buffer(Okio.source(new ByteArrayInputStream(content)))); if (null != overrides && null != overrides.libConfig) { - receivedOverrides = true; - applyConfigOverrides(checkConfig(overrides.libConfig)); + configs.put(configKey.getConfigId(), overrides); if (log.isDebugEnabled()) { log.debug( - "Applied APM_TRACING overrides: {}", CONFIG_OVERRIDES_ADAPTER.toJson(overrides)); + "Applied APM_TRACING overrides: {} - priority: {}", + CONFIG_OVERRIDES_ADAPTER.toJson(overrides), + overrides.getOverridePriority()); } } else { log.debug("No APM_TRACING overrides"); @@ -127,15 +134,33 @@ public void accept(ConfigKey configKey, byte[] content, PollingRateHinter hinter } @Override - public void remove(ConfigKey configKey, PollingRateHinter hinter) {} + public void remove(ConfigKey configKey, PollingRateHinter hinter) { + configs.remove(configKey.getConfigId()); + } @Override public void commit(PollingRateHinter hinter) { - if (!receivedOverrides) { + // sort configs by override priority + List sortedConfigs = + configs.values().stream() + .sorted(Comparator.comparingInt(ConfigOverrides::getOverridePriority).reversed()) + .map(config -> config.libConfig) + .collect(Collectors.toList()); + + LibConfig mergedConfig = LibConfig.mergeLibConfigs(sortedConfigs); + + if (mergedConfig != null) { + // apply merged config + if (log.isDebugEnabled()) { + log.debug( + "Applying merged APM_TRACING config: {}", LIB_CONFIG_ADAPTER.toJson(mergedConfig)); + } + applyConfigOverrides(checkConfig(mergedConfig)); + } + + if (sortedConfigs.isEmpty()) { removeConfigOverrides(); log.debug("Removed APM_TRACING overrides"); - } else { - receivedOverrides = false; } } @@ -263,6 +288,77 @@ private Map parseTagListToMap(List input) { static final class ConfigOverrides { @Json(name = "lib_config") public LibConfig libConfig; + + @Json(name = "service_target") + public ServiceTarget serviceTarget; + + @Json(name = "k8s_target_v2") + public K8sTargetV2 k8sTargetV2; + + public int getOverridePriority() { + boolean isSingleEnvironment = isSingleEnvironment(); + boolean isSingleService = isSingleService(); + boolean isClusterTarget = isClusterTarget(); + + // Service+ Environment level override - highest priority + if (isSingleEnvironment && isSingleService) { + return 5; + } + + if (isSingleService) { + return 4; + } + + if (isSingleEnvironment) { + return 3; + } + + if (isClusterTarget) { + return 2; + } + + // Org level override - lowest priority + return 1; + } + + // allEnvironments = serviceTarget is null or serviceTarget.env is null or '*' + public boolean isSingleEnvironment() { + return serviceTarget != null && serviceTarget.env != null && !"*".equals(serviceTarget.env); + } + + public boolean isSingleService() { + return serviceTarget != null + && serviceTarget.service != null + && !"*".equals(serviceTarget.service); + } + + public boolean isClusterTarget() { + return k8sTargetV2 != null; + } + } + + static final class ServiceTarget { + @Json(name = "service") + public String service; + + @Json(name = "env") + public String env; + } + + static final class K8sTargetV2 { + @Json(name = "cluster_targets") + public List clusterTargets; + } + + static final class ClusterTarget { + @Json(name = "cluster_name") + public String clusterName; + + @Json(name = "enabled") + public Boolean enabled; + + @Json(name = "enabled_namespaces") + public List enabledNamespaces; } static final class LibConfig { @@ -307,6 +403,71 @@ static final class LibConfig { @Json(name = "live_debugging_enabled") public Boolean liveDebuggingEnabled; + + /** + * Merges a list of LibConfig objects by taking the first non-null value for each field. + * + * @param configs the list of LibConfig objects to merge + * @return a merged LibConfig object, or null if the input list is null or empty + */ + public static LibConfig mergeLibConfigs(List configs) { + if (configs == null || configs.isEmpty()) { + return null; + } + + LibConfig merged = new LibConfig(); + + for (LibConfig config : configs) { + if (config == null) { + continue; + } + + if (merged.tracingEnabled == null) { + merged.tracingEnabled = config.tracingEnabled; + } + if (merged.debugEnabled == null) { + merged.debugEnabled = config.debugEnabled; + } + if (merged.runtimeMetricsEnabled == null) { + merged.runtimeMetricsEnabled = config.runtimeMetricsEnabled; + } + if (merged.logsInjectionEnabled == null) { + merged.logsInjectionEnabled = config.logsInjectionEnabled; + } + if (merged.dataStreamsEnabled == null) { + merged.dataStreamsEnabled = config.dataStreamsEnabled; + } + if (merged.serviceMapping == null) { + merged.serviceMapping = config.serviceMapping; + } + if (merged.headerTags == null) { + merged.headerTags = config.headerTags; + } + if (merged.traceSampleRate == null) { + merged.traceSampleRate = config.traceSampleRate; + } + if (merged.tracingTags == null) { + merged.tracingTags = config.tracingTags; + } + if (merged.tracingSamplingRules == null) { + merged.tracingSamplingRules = config.tracingSamplingRules; + } + if (merged.dynamicInstrumentationEnabled == null) { + merged.dynamicInstrumentationEnabled = config.dynamicInstrumentationEnabled; + } + if (merged.exceptionReplayEnabled == null) { + merged.exceptionReplayEnabled = config.exceptionReplayEnabled; + } + if (merged.codeOriginEnabled == null) { + merged.codeOriginEnabled = config.codeOriginEnabled; + } + if (merged.liveDebuggingEnabled == null) { + merged.liveDebuggingEnabled = config.liveDebuggingEnabled; + } + } + + return merged; + } } /** Holds the raw JSON string and the parsed rule data. */ diff --git a/dd-trace-core/src/test/groovy/datadog/trace/core/TracingConfigPollerTest.groovy b/dd-trace-core/src/test/groovy/datadog/trace/core/TracingConfigPollerTest.groovy new file mode 100644 index 00000000000..63d70911f47 --- /dev/null +++ b/dd-trace-core/src/test/groovy/datadog/trace/core/TracingConfigPollerTest.groovy @@ -0,0 +1,280 @@ +package datadog.trace.core + +import datadog.communication.ddagent.DDAgentFeaturesDiscovery +import datadog.communication.ddagent.SharedCommunicationObjects +import datadog.communication.monitor.Monitoring +import datadog.remoteconfig.ConfigurationPoller +import datadog.remoteconfig.Product +import datadog.remoteconfig.state.ParsedConfigKey +import datadog.remoteconfig.state.ProductListener +import datadog.trace.core.test.DDCoreSpecification +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import spock.lang.Timeout + +import java.nio.charset.StandardCharsets + +@Timeout(10) +class TracingConfigPollerTest extends DDCoreSpecification { + + def "test mergeLibConfigs with null and non-null values"() { + setup: + def config1 = new TracingConfigPoller.LibConfig() // all nulls + def config2 = new TracingConfigPoller.LibConfig( + tracingEnabled: true, + debugEnabled: false, + runtimeMetricsEnabled: true, + logsInjectionEnabled: false, + dataStreamsEnabled: true, + traceSampleRate: 0.5, + dynamicInstrumentationEnabled: true, + exceptionReplayEnabled: false, + codeOriginEnabled: true, + liveDebuggingEnabled: false + ) + def config3 = new TracingConfigPoller.LibConfig( + tracingEnabled: false, + debugEnabled: true, + runtimeMetricsEnabled: false, + logsInjectionEnabled: true, + dataStreamsEnabled: false, + traceSampleRate: 0.8, + dynamicInstrumentationEnabled: false, + exceptionReplayEnabled: true, + codeOriginEnabled: false, + liveDebuggingEnabled: true + ) + + when: + def merged = TracingConfigPoller.LibConfig.mergeLibConfigs([config1, config2, config3]) + + then: + merged != null + // Should take first non-null values from config2 + merged.tracingEnabled == true + merged.debugEnabled == false + merged.runtimeMetricsEnabled == true + merged.logsInjectionEnabled == false + merged.dataStreamsEnabled == true + merged.traceSampleRate == 0.5 + merged.dynamicInstrumentationEnabled == true + merged.exceptionReplayEnabled == false + merged.codeOriginEnabled == true + merged.liveDebuggingEnabled == false + } + + def "test config priority calculation"() { + setup: + def configOverrides = new TracingConfigPoller.ConfigOverrides() + if (service != null || env != null) { + configOverrides.serviceTarget = new TracingConfigPoller.ServiceTarget( + service: service, + env: env, + ) + } + if (clusterName != null) { + configOverrides.k8sTargetV2 = new TracingConfigPoller.K8sTargetV2( + clusterTargets: [ + new TracingConfigPoller.ClusterTarget( + clusterName: clusterName, + enabled: true, + ) + ] + ) + } + configOverrides.libConfig = new TracingConfigPoller.LibConfig() + + when: + def priority = configOverrides.getOverridePriority() + + then: + priority == expectedPriority + + where: + service | env | clusterName | expectedPriority + "test-service" | "staging" | null | 5 + "test-service" | "*" | null | 4 + "*" | "staging" | null | 3 + null | null | "test-cluster" | 2 + "*" | "*" | null | 1 + } + + + def "test actual config commit with service and org level configs"() { + setup: + def orgKey = ParsedConfigKey.parse("datadog/2/APM_TRACING/org_config/config") + def serviceKey = ParsedConfigKey.parse("datadog/2/APM_TRACING/service_config/config") + def poller = Mock(ConfigurationPoller) + def sco = new SharedCommunicationObjects( + okHttpClient: Mock(OkHttpClient), + monitoring: Mock(Monitoring), + agentUrl: HttpUrl.get('https://example.com'), + featuresDiscovery: Mock(DDAgentFeaturesDiscovery), + configurationPoller: poller + ) + + def updater + + when: + def tracer = CoreTracer.builder() + .sharedCommunicationObjects(sco) + .pollForTracingConfiguration() + .build() + + then: + 1 * poller.addListener(Product.APM_TRACING, _ as ProductListener) >> { + updater = it[1] // capture config updater for further testing + } + and: + tracer.captureTraceConfig().serviceMapping == [:] + tracer.captureTraceConfig().traceSampleRate == null + + when: + // Add org level config (priority 1) - should set service mapping + updater.accept(orgKey, """ + { + "service_target": { + "service": "*", + "env": "*" + }, + "lib_config": { + "tracing_service_mapping": [ + { + "from_key": "org-service", + "to_name": "org-mapped" + } + ], + "tracing_sampling_rate": 0.7 + } + } + """.getBytes(StandardCharsets.UTF_8), null) + + // Add service level config (priority 4) - should override service mapping and add header tags + updater.accept(serviceKey, """ + { + "service_target": { + "service": "test-service", + "env": "*" + }, + "lib_config": { + "tracing_service_mapping": [ + { + "from_key": "service-specific", + "to_name": "service-mapped" + } + ], + "tracing_header_tags": [ + { + "header": "X-Custom-Header", + "tag_name": "custom.header" + } + ], + "tracing_sampling_rate": 1.3 + } + } + """.getBytes(StandardCharsets.UTF_8), null) + + // Commit both configs + updater.commit() + + then: + // Service level config should take precedence due to higher priority (4 vs 1) + tracer.captureTraceConfig().serviceMapping == ["service-specific": "service-mapped"] + tracer.captureTraceConfig().traceSampleRate == 1.0 // should be clamped to 1.0 + tracer.captureTraceConfig().requestHeaderTags == ["x-custom-header": "custom.header"] + tracer.captureTraceConfig().responseHeaderTags == ["x-custom-header": "custom.header"] + + when: + // Remove service level config + updater.remove(serviceKey, null) + updater.commit() + + then: + // Should fall back to org level config + tracer.captureTraceConfig().serviceMapping == ["org-service": "org-mapped"] + tracer.captureTraceConfig().traceSampleRate == 0.7 + tracer.captureTraceConfig().requestHeaderTags == [:] + tracer.captureTraceConfig().responseHeaderTags == [:] + + when: + // Remove org level config + updater.remove(orgKey, null) + updater.commit() + + then: + // Should have no configs + tracer.captureTraceConfig().serviceMapping == [:] + tracer.captureTraceConfig().traceSampleRate == null + + cleanup: + tracer?.close() + } + + def "test two org levels config setting different flags works"() { + setup: + def orgConfig1Key = ParsedConfigKey.parse("datadog/2/APM_TRACING/org_config/config1") + def orgConfig2Key = ParsedConfigKey.parse("datadog/2/APM_TRACING/org_config/config2") + def poller = Mock(ConfigurationPoller) + def sco = new SharedCommunicationObjects( + okHttpClient: Mock(OkHttpClient), + monitoring: Mock(Monitoring), + agentUrl: HttpUrl.get('https://example.com'), + featuresDiscovery: Mock(DDAgentFeaturesDiscovery), + configurationPoller: poller + ) + + def updater + + when: + def tracer = CoreTracer.builder() + .sharedCommunicationObjects(sco) + .pollForTracingConfiguration() + .build() + + then: + 1 * poller.addListener(Product.APM_TRACING, _ as ProductListener) >> { + updater = it[1] // capture config updater for further testing + } + and: + tracer.captureTraceConfig().isTraceEnabled() == true + tracer.captureTraceConfig().isDataStreamsEnabled() == false + + when: + // Add org level config with ApmTracing enabled + updater.accept(orgConfig1Key, """ + { + "service_target": { + "service": "*", + "env": "*" + }, + "lib_config": { + "tracing_enabled": true + } + } + """.getBytes(StandardCharsets.UTF_8), null) + + // Add second org level config with DataStreams enabled + updater.accept(orgConfig2Key, """ + { + "service_target": { + "service": "*", + "env": "*" + }, + "lib_config": { + "data_streams_enabled": true + } + } + """.getBytes(StandardCharsets.UTF_8), null) + + // Commit both configs + updater.commit() + + then: + // Both org level configs should be merged, with data streams enabled + tracer.captureTraceConfig().isTraceEnabled() == true + tracer.captureTraceConfig().isDataStreamsEnabled() == true + + cleanup: + tracer?.close() + } +} diff --git a/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Capabilities.java b/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Capabilities.java index b8e1124a1b2..7ddd4e0c693 100644 --- a/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Capabilities.java +++ b/remote-config/remote-config-api/src/main/java/datadog/remoteconfig/Capabilities.java @@ -42,4 +42,8 @@ public interface Capabilities { long CAPABILITY_APM_TRACING_ENABLE_EXCEPTION_REPLAY = 1L << 39; long CAPABILITY_APM_TRACING_ENABLE_CODE_ORIGIN = 1L << 40; long CAPABILITY_APM_TRACING_ENABLE_LIVE_DEBUGGING = 1L << 41; + long CAPABILITY_ASM_DD_MULTICONFIG = 1L << 42; + long CAPABILITY_ASM_TRACE_TAGGING_RULES = 1L << 43; + long CAPABILITY_ASM_EXTENDED_DATA_COLLECTION = 1L << 44; + long CAPABILITY_APM_TRACING_MULTICONFIG = 1L << 45; }