diff --git a/.circleci/config.continue.yml.j2 b/.circleci/config.continue.yml.j2 index 1a01fa35981..026026ea390 100644 --- a/.circleci/config.continue.yml.j2 +++ b/.circleci/config.continue.yml.j2 @@ -36,7 +36,7 @@ instrumentation_modules: &instrumentation_modules "dd-java-agent/instrumentation debugger_modules: &debugger_modules "dd-java-agent/agent-debugger|dd-java-agent/agent-bootstrap|dd-java-agent/agent-builder|internal-api|communication|dd-trace-core" profiling_modules: &profiling_modules "dd-java-agent/agent-profiling" -default_system_tests_commit: &default_system_tests_commit 97bada5205ff411ec62ba9c2c15e66cd5a49fbb0 +default_system_tests_commit: &default_system_tests_commit 2487cea5160a398549743d2cfd927a863792e3bd parameters: nightly: diff --git a/communication/src/main/java/datadog/communication/ddagent/DDAgentFeaturesDiscovery.java b/communication/src/main/java/datadog/communication/ddagent/DDAgentFeaturesDiscovery.java index 1f1f73f1b7a..ae3c375875e 100644 --- a/communication/src/main/java/datadog/communication/ddagent/DDAgentFeaturesDiscovery.java +++ b/communication/src/main/java/datadog/communication/ddagent/DDAgentFeaturesDiscovery.java @@ -47,6 +47,8 @@ public class DDAgentFeaturesDiscovery implements DroppingPolicy { public static final String DEBUGGER_ENDPOINT = "debugger/v1/input"; + public static final String TELEMETRY_PROXY_ENDPOINT = "telemetry/proxy/"; + private static final long MIN_FEATURE_DISCOVERY_INTERVAL_MILLIS = 60 * 1000; private final OkHttpClient client; @@ -58,6 +60,7 @@ public class DDAgentFeaturesDiscovery implements DroppingPolicy { private final boolean metricsEnabled; private final String[] dataStreamsEndpoints = {V01_DATASTREAMS_ENDPOINT}; private final String[] evpProxyEndpoints = {V2_EVP_PROXY_ENDPOINT}; + private final String[] telemetryProxyEndpoints = {TELEMETRY_PROXY_ENDPOINT}; private volatile String traceEndpoint; private volatile String metricsEndpoint; @@ -69,6 +72,7 @@ public class DDAgentFeaturesDiscovery implements DroppingPolicy { private volatile String debuggerEndpoint; private volatile String evpProxyEndpoint; private volatile String version; + private volatile String telemetryProxyEndpoint; private long lastTimeDiscovered; @@ -100,6 +104,7 @@ private void reset() { evpProxyEndpoint = null; version = null; lastTimeDiscovered = 0; + telemetryProxyEndpoint = null; } /** Run feature discovery, unconditionally. */ @@ -162,14 +167,15 @@ private void doDiscovery() { if (log.isDebugEnabled()) { log.debug( - "discovered traceEndpoint={}, metricsEndpoint={}, supportsDropping={}, supportsLongRunning={}, dataStreamsEndpoint={}, configEndpoint={}, evpProxyEndpoint={}", + "discovered traceEndpoint={}, metricsEndpoint={}, supportsDropping={}, supportsLongRunning={}, dataStreamsEndpoint={}, configEndpoint={}, evpProxyEndpoint={}, telemetryProxyEndpoint={}", traceEndpoint, metricsEndpoint, supportsDropping, supportsLongRunning, dataStreamsEndpoint, configEndpoint, - evpProxyEndpoint); + evpProxyEndpoint, + telemetryProxyEndpoint); } } @@ -247,6 +253,13 @@ private boolean processInfoResponse(String response) { } } + for (String endpoint : telemetryProxyEndpoints) { + if (endpoints.contains(endpoint) || endpoints.contains("/" + endpoint)) { + telemetryProxyEndpoint = endpoint; + break; + } + } + supportsLongRunning = Boolean.TRUE.equals(map.getOrDefault("long_running_spans", false)); if (metricsEnabled) { @@ -352,4 +365,8 @@ public String state() { public boolean active() { return supportsMetrics() && supportsDropping; } + + public boolean supportsTelemetryProxy() { + return telemetryProxyEndpoint != null; + } } diff --git a/communication/src/test/groovy/datadog/communication/ddagent/DDAgentFeaturesDiscoveryTest.groovy b/communication/src/test/groovy/datadog/communication/ddagent/DDAgentFeaturesDiscoveryTest.groovy index af9b865d1b4..70b5519d355 100644 --- a/communication/src/test/groovy/datadog/communication/ddagent/DDAgentFeaturesDiscoveryTest.groovy +++ b/communication/src/test/groovy/datadog/communication/ddagent/DDAgentFeaturesDiscoveryTest.groovy @@ -15,7 +15,6 @@ import spock.lang.Shared import java.nio.file.Files import java.nio.file.Paths -import java.util.concurrent.CountDownLatch import static datadog.communication.ddagent.DDAgentFeaturesDiscovery.V01_DATASTREAMS_ENDPOINT import static datadog.communication.ddagent.DDAgentFeaturesDiscovery.V6_METRICS_ENDPOINT @@ -38,6 +37,7 @@ class DDAgentFeaturesDiscoveryTest extends DDSpecification { static final String INFO_WITHOUT_DATA_STREAMS_RESPONSE = loadJsonFile("agent-info-without-data-streams.json") static final String INFO_WITHOUT_DATA_STREAMS_STATE = Strings.sha256(INFO_WITHOUT_DATA_STREAMS_RESPONSE) static final String INFO_WITH_LONG_RUNNING_SPANS = loadJsonFile("agent-info-with-long-running-spans.json") + static final String INFO_WITH_TELEMETRY_PROXY_RESPONSE = loadJsonFile("agent-info-with-telemetry-proxy.json") static final String PROBE_STATE = "probestate" def "test parse /info response"() { @@ -62,6 +62,7 @@ class DDAgentFeaturesDiscoveryTest extends DDSpecification { features.supportsEvpProxy() features.getVersion() == "0.99.0" !features.supportsLongRunning() + !features.supportsTelemetryProxy() 0 * _ } @@ -89,6 +90,7 @@ class DDAgentFeaturesDiscoveryTest extends DDSpecification { features.supportsEvpProxy() features.getVersion() == "0.99.0" !features.supportsLongRunning() + !features.supportsTelemetryProxy() 0 * _ } @@ -384,17 +386,22 @@ class DDAgentFeaturesDiscoveryTest extends DDSpecification { // but we don't permit dropping anyway !(features as DroppingPolicy).active() features.state() == INFO_WITHOUT_METRICS_STATE + !features.supportsTelemetryProxy() 0 * _ } - def countingNotFound(Request request, CountDownLatch latch) { - latch.countDown() - return notFound(request) - } + def "test parse /info response with telemetry proxy"() { + setup: + OkHttpClient client = Mock(OkHttpClient) + DDAgentFeaturesDiscovery features = new DDAgentFeaturesDiscovery(client, monitoring, agentUrl, true, true) - def countingInfoResponse(Request request, String json, CountDownLatch latch) { - latch.countDown() - return infoResponse(request, json) + when: "/info available" + features.discover() + + then: + 1 * client.newCall(_) >> { Request request -> infoResponse(request, INFO_WITH_TELEMETRY_PROXY_RESPONSE) } + features.supportsTelemetryProxy() + 0 * _ } def infoResponse(Request request, String json) { diff --git a/communication/src/test/resources/agent-features/agent-info-with-telemetry-proxy.json b/communication/src/test/resources/agent-features/agent-info-with-telemetry-proxy.json new file mode 100644 index 00000000000..55e493e8316 --- /dev/null +++ b/communication/src/test/resources/agent-features/agent-info-with-telemetry-proxy.json @@ -0,0 +1,62 @@ +{ + "version": "0.99.0", + "git_commit": "fab047e10", + "build_date": "2020-12-04 15:57:06.74187 +0200 EET m=+0.029001792", + "endpoints": [ + "/v0.3/traces", + "/v0.3/services", + "/v0.4/traces", + "/v0.4/services", + "/v0.5/traces", + "/v0.6/stats", + "/profiling/v1/input", + "/telemetry/proxy/", + "/v0.1/pipeline_stats", + "/evp_proxy/v1/", + "/evp_proxy/v2/", + "/debugger/v1/input", + "/v0.7/config" + ], + "feature_flags": [ + "feature_flag" + ], + "config": { + "default_env": "prod", + "bucket_interval": 1000000000, + "extra_aggregators": [ + "agg:val" + ], + "extra_sample_rate": 2.4, + "target_tps": 11, + "max_eps": 12, + "receiver_port": 8111, + "receiver_socket": "/sock/path", + "connection_limit": 12, + "receiver_timeout": 100, + "max_request_bytes": 123, + "statsd_port": 123, + "max_memory": 1000000, + "max_cpu": 12345, + "analyzed_rate_by_service_legacy": { + "X": 1.2 + }, + "analyzed_spans_by_service": { + "X": { + "Y": 2.4 + } + }, + "obfuscation": { + "elastic_search": true, + "mongo": true, + "sql_exec_plan": true, + "sql_exec_plan_normalize": true, + "http": { + "remove_query_string": true, + "remove_path_digits": true + }, + "remove_stack_traces": false, + "redis": true, + "memcached": false + } + } +} diff --git a/dd-java-agent/instrumentation/undertow/undertow-2.2/src/test/groovy/UndertowServletTest.groovy b/dd-java-agent/instrumentation/undertow/undertow-2.2/src/test/groovy/UndertowServletTest.groovy index eb967148a88..dd3769209b8 100644 --- a/dd-java-agent/instrumentation/undertow/undertow-2.2/src/test/groovy/UndertowServletTest.groovy +++ b/dd-java-agent/instrumentation/undertow/undertow-2.2/src/test/groovy/UndertowServletTest.groovy @@ -57,7 +57,6 @@ class UndertowServletTest extends HttpServerTest { DeploymentManager manager = container.addDeployment(builder) manager.deploy() - System.out.println(">>> builder.getContextPath(): " + builder.getContextPath()) root.addPrefixPath(builder.getContextPath(), manager.start()) undertowServer = Undertow.builder() diff --git a/dd-java-agent/src/test/groovy/datadog/trace/agent/CustomLogManagerTest.groovy b/dd-java-agent/src/test/groovy/datadog/trace/agent/CustomLogManagerTest.groovy index fad6e50cb2e..9ff2d97e32d 100644 --- a/dd-java-agent/src/test/groovy/datadog/trace/agent/CustomLogManagerTest.groovy +++ b/dd-java-agent/src/test/groovy/datadog/trace/agent/CustomLogManagerTest.groovy @@ -55,7 +55,7 @@ class CustomLogManagerTest extends Specification { "-Djava.util.logging.manager=jvmbootstraptest.MissingLogManager" ] as String[] , "" as String[] - , ["DD_API_KEY": API_KEY] + , ["DD_API_KEY": API_KEY, "DD_SITE": ""] , true) == 0 } @@ -87,7 +87,7 @@ class CustomLogManagerTest extends Specification { "-Ddd.app.customjmxbuilder=false" ] as String[] , "" as String[] - , ["JBOSS_HOME": "/", "DD_API_KEY": API_KEY] + , ["JBOSS_HOME": "/", "DD_API_KEY": API_KEY, "DD_SITE": ""] , true) == 0 } diff --git a/dd-smoke-tests/log-injection/src/main/java/datadog/smoketest/loginjection/BaseApplication.java b/dd-smoke-tests/log-injection/src/main/java/datadog/smoketest/loginjection/BaseApplication.java index 260ec281797..a2edae29ac3 100644 --- a/dd-smoke-tests/log-injection/src/main/java/datadog/smoketest/loginjection/BaseApplication.java +++ b/dd-smoke-tests/log-injection/src/main/java/datadog/smoketest/loginjection/BaseApplication.java @@ -3,6 +3,7 @@ import static datadog.trace.api.config.TraceInstrumentationConfig.LOGS_INJECTION_ENABLED; import datadog.trace.api.ConfigCollector; +import datadog.trace.api.ConfigSetting; import datadog.trace.api.CorrelationIdentifier; import datadog.trace.api.Trace; import java.util.concurrent.TimeUnit; @@ -22,15 +23,13 @@ public void run() throws InterruptedException { secondTracedMethod(); - if (!waitForCondition( - () -> Boolean.FALSE.equals(ConfigCollector.get().collect().get(LOGS_INJECTION_ENABLED)))) { + if (!waitForCondition(() -> Boolean.FALSE.equals(getLogInjectionEnabled()))) { throw new RuntimeException("Logs injection config was never updated"); } thirdTracedMethod(); - if (!waitForCondition( - () -> Boolean.TRUE.equals(ConfigCollector.get().collect().get(LOGS_INJECTION_ENABLED)))) { + if (!waitForCondition(() -> Boolean.TRUE.equals(getLogInjectionEnabled()))) { throw new RuntimeException("Logs injection config was never updated a second time"); } @@ -44,6 +43,14 @@ public void run() throws InterruptedException { Thread.sleep(400); } + private static Object getLogInjectionEnabled() { + ConfigSetting configSetting = ConfigCollector.get().collect().get(LOGS_INJECTION_ENABLED); + if (configSetting == null) { + return null; + } + return configSetting.value; + } + @Trace public void firstTracedMethod() { doLog("INSIDE FIRST SPAN"); diff --git a/dd-smoke-tests/src/main/groovy/datadog/smoketest/AbstractSmokeTest.groovy b/dd-smoke-tests/src/main/groovy/datadog/smoketest/AbstractSmokeTest.groovy index a88dccdab73..e8d30b7ab31 100644 --- a/dd-smoke-tests/src/main/groovy/datadog/smoketest/AbstractSmokeTest.groovy +++ b/dd-smoke-tests/src/main/groovy/datadog/smoketest/AbstractSmokeTest.groovy @@ -102,7 +102,8 @@ abstract class AbstractSmokeTest extends ProcessManager { "-Ddd.profiling.ddprof.enabled=true", "-Ddd.profiling.ddprof.alloc.enabled=true", "-Ddatadog.slf4j.simpleLogger.defaultLogLevel=${logLevel()}", - "-Dorg.slf4j.simpleLogger.defaultLogLevel=${logLevel()}" + "-Dorg.slf4j.simpleLogger.defaultLogLevel=${logLevel()}", + "-Ddd.site=" ] @Shared diff --git a/dd-trace-api/src/main/java/datadog/trace/api/ConfigDefaults.java b/dd-trace-api/src/main/java/datadog/trace/api/ConfigDefaults.java index cca7f3c3007..24cd05cacdf 100644 --- a/dd-trace-api/src/main/java/datadog/trace/api/ConfigDefaults.java +++ b/dd-trace-api/src/main/java/datadog/trace/api/ConfigDefaults.java @@ -177,6 +177,8 @@ public final class ConfigDefaults { static final boolean DEFAULT_TELEMETRY_ENABLED = true; static final int DEFAULT_TELEMETRY_HEARTBEAT_INTERVAL = 60; // in seconds + static final int DEFAULT_TELEMETRY_EXTENDED_HEARTBEAT_INTERVAL = + 24 * 60 * 60; // 24 hours in seconds static final int DEFAULT_TELEMETRY_METRICS_INTERVAL = 10; // in seconds static final boolean DEFAULT_TELEMETRY_DEPENDENCY_COLLECTION_ENABLED = true; @@ -197,8 +199,8 @@ public final class ConfigDefaults { static final boolean DEFAULT_ELASTICSEARCH_BODY_AND_PARAMS_ENABLED = false; static final boolean DEFAULT_SPARK_TASK_HISTOGRAM_ENABLED = true; - static final boolean DEFAULT_JAX_RS_EXCEPTION_AS_ERROR_ENABLED = true; + static final boolean DEFAULT_TELEMETRY_DEBUG_REQUESTS_ENABLED = false; private ConfigDefaults() {} } diff --git a/dd-trace-api/src/main/java/datadog/trace/api/config/GeneralConfig.java b/dd-trace-api/src/main/java/datadog/trace/api/config/GeneralConfig.java index 2ac336a40a9..b7448701013 100644 --- a/dd-trace-api/src/main/java/datadog/trace/api/config/GeneralConfig.java +++ b/dd-trace-api/src/main/java/datadog/trace/api/config/GeneralConfig.java @@ -66,9 +66,14 @@ public final class GeneralConfig { public static final String TELEMETRY_ENABLED = "instrumentation.telemetry.enabled"; public static final String TELEMETRY_HEARTBEAT_INTERVAL = "telemetry.heartbeat.interval"; + public static final String TELEMETRY_EXTENDED_HEARTBEAT_INTERVAL = + "telemetry.extended.heartbeat.interval"; public static final String TELEMETRY_METRICS_INTERVAL = "telemetry.metrics.interval"; + public static final String TELEMETRY_METRICS_ENABLED = "telemetry.metrics.enabled"; public static final String TELEMETRY_DEPENDENCY_COLLECTION_ENABLED = "telemetry.dependency-collection.enabled"; + public static final String TELEMETRY_DEBUG_REQUESTS_ENABLED = "telemetry.debug.requests.enabled"; + private GeneralConfig() {} } 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 b69faefa4f3..5f0aceaef72 100644 --- a/internal-api/src/main/java/datadog/trace/api/Config.java +++ b/internal-api/src/main/java/datadog/trace/api/Config.java @@ -94,6 +94,7 @@ import static datadog.trace.api.ConfigDefaults.DEFAULT_SITE; import static datadog.trace.api.ConfigDefaults.DEFAULT_STARTUP_LOGS_ENABLED; import static datadog.trace.api.ConfigDefaults.DEFAULT_TELEMETRY_DEPENDENCY_COLLECTION_ENABLED; +import static datadog.trace.api.ConfigDefaults.DEFAULT_TELEMETRY_EXTENDED_HEARTBEAT_INTERVAL; import static datadog.trace.api.ConfigDefaults.DEFAULT_TELEMETRY_HEARTBEAT_INTERVAL; import static datadog.trace.api.ConfigDefaults.DEFAULT_TELEMETRY_METRICS_INTERVAL; import static datadog.trace.api.ConfigDefaults.DEFAULT_TRACE_128_BIT_TRACEID_GENERATION_ENABLED; @@ -221,6 +222,7 @@ import static datadog.trace.api.config.GeneralConfig.STATSD_CLIENT_SOCKET_TIMEOUT; import static datadog.trace.api.config.GeneralConfig.TAGS; import static datadog.trace.api.config.GeneralConfig.TELEMETRY_DEPENDENCY_COLLECTION_ENABLED; +import static datadog.trace.api.config.GeneralConfig.TELEMETRY_EXTENDED_HEARTBEAT_INTERVAL; import static datadog.trace.api.config.GeneralConfig.TELEMETRY_HEARTBEAT_INTERVAL; import static datadog.trace.api.config.GeneralConfig.TELEMETRY_METRICS_INTERVAL; import static datadog.trace.api.config.GeneralConfig.TRACER_METRICS_BUFFERING_ENABLED; @@ -811,8 +813,10 @@ static class HostNameHolder { private final boolean iastDeduplicationEnabled; private final float telemetryHeartbeatInterval; + private final long telemetryExtendedHeartbeatInterval; private final float telemetryMetricsInterval; private final boolean isTelemetryDependencyServiceEnabled; + private final boolean telemetryMetricsEnabled; private final boolean azureAppServices; private final String traceAgentPath; @@ -836,6 +840,8 @@ static class HostNameHolder { private final float traceFlushIntervalSeconds; + private final boolean telemetryDebugRequestsEnabled; + // Read order: System Properties -> Env Variables, [-> properties file], [-> default value] private Config() { this(ConfigProvider.createDefault()); @@ -1439,6 +1445,10 @@ private Config(final ConfigProvider configProvider, final InstrumenterConfig ins } telemetryHeartbeatInterval = telemetryInterval; + telemetryExtendedHeartbeatInterval = + configProvider.getLong( + TELEMETRY_EXTENDED_HEARTBEAT_INTERVAL, DEFAULT_TELEMETRY_EXTENDED_HEARTBEAT_INTERVAL); + telemetryInterval = configProvider.getFloat(TELEMETRY_METRICS_INTERVAL, DEFAULT_TELEMETRY_METRICS_INTERVAL); if (telemetryInterval < 0.1 || telemetryInterval > 3600) { @@ -1449,6 +1459,9 @@ private Config(final ConfigProvider configProvider, final InstrumenterConfig ins } telemetryMetricsInterval = telemetryInterval; + telemetryMetricsEnabled = + configProvider.getBoolean(GeneralConfig.TELEMETRY_METRICS_ENABLED, true); + isTelemetryDependencyServiceEnabled = configProvider.getBoolean( TELEMETRY_DEPENDENCY_COLLECTION_ENABLED, @@ -1837,6 +1850,11 @@ private Config(final ConfigProvider configProvider, final InstrumenterConfig ins + "Please ensure that either an API key is configured, or the tracer is set up to work with the Agent"); } + this.telemetryDebugRequestsEnabled = + configProvider.getBoolean( + GeneralConfig.TELEMETRY_DEBUG_REQUESTS_ENABLED, + ConfigDefaults.DEFAULT_TELEMETRY_DEBUG_REQUESTS_ENABLED); + log.debug("New instance: {}", this); } @@ -2424,6 +2442,10 @@ public float getTelemetryHeartbeatInterval() { return telemetryHeartbeatInterval; } + public long getTelemetryExtendedHeartbeatInterval() { + return telemetryExtendedHeartbeatInterval; + } + public float getTelemetryMetricsInterval() { return telemetryMetricsInterval; } @@ -2432,6 +2454,10 @@ public boolean isTelemetryDependencyServiceEnabled() { return isTelemetryDependencyServiceEnabled; } + public boolean isTelemetryMetricsEnabled() { + return telemetryMetricsEnabled; + } + public boolean isClientIpEnabled() { return clientIpEnabled; } @@ -3447,6 +3473,10 @@ public static boolean traceAnalyticsIntegrationEnabled( return Config.get().isTraceAnalyticsIntegrationEnabled(integrationNames, defaultEnabled); } + public boolean isTelemetryDebugRequestsEnabled() { + return telemetryDebugRequestsEnabled; + } + private Set getSettingsSetFromEnvironment( String name, Function mapper, boolean splitOnWS) { final String value = configProvider.getString(name, ""); @@ -3619,7 +3649,7 @@ private static boolean isWindowsOS() { private static String getEnv(String name) { String value = System.getenv(name); if (value != null) { - ConfigCollector.get().put(name, value); + ConfigCollector.get().put(name, value, ConfigOrigin.ENV); } return value; } @@ -3642,7 +3672,7 @@ private static String getProp(String name) { private static String getProp(String name, String def) { String value = System.getProperty(name, def); if (value != null) { - ConfigCollector.get().put(name, value); + ConfigCollector.get().put(name, value, ConfigOrigin.JVM_PROP); } return value; } @@ -4046,6 +4076,10 @@ public String toString() { + removeIntegrationServiceNamesEnabled + ", spanAttributeSchemaVersion=" + spanAttributeSchemaVersion + + ", telemetryDebugRequestsEnabled=" + + telemetryDebugRequestsEnabled + + ", telemetryMetricsEnabled=" + + telemetryMetricsEnabled + '}'; } } diff --git a/internal-api/src/main/java/datadog/trace/api/ConfigCollector.java b/internal-api/src/main/java/datadog/trace/api/ConfigCollector.java index 223ac2905e6..c55f1f309b0 100644 --- a/internal-api/src/main/java/datadog/trace/api/ConfigCollector.java +++ b/internal-api/src/main/java/datadog/trace/api/ConfigCollector.java @@ -1,10 +1,7 @@ package datadog.trace.api; -import java.util.Arrays; import java.util.Collections; -import java.util.HashSet; import java.util.Map; -import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicReferenceFieldUpdater; @@ -14,32 +11,32 @@ * consumers. So this is based on a ConcurrentHashMap to deal with it. */ public class ConfigCollector { - - private static final Set CONFIG_FILTER_LIST = - new HashSet<>( - Arrays.asList("DD_API_KEY", "dd.api-key", "dd.profiling.api-key", "dd.profiling.apikey")); - private static final ConfigCollector INSTANCE = new ConfigCollector(); private static final AtomicReferenceFieldUpdater COLLECTED_UPDATER = AtomicReferenceFieldUpdater.newUpdater(ConfigCollector.class, Map.class, "collected"); - private volatile Map collected = new ConcurrentHashMap<>(); + private volatile Map collected = new ConcurrentHashMap<>(); public static ConfigCollector get() { return INSTANCE; } - public void put(String key, Object value) { - collected.put(key, filterConfigEntry(key, value)); + public void put(String key, Object value, ConfigOrigin origin) { + ConfigSetting setting = new ConfigSetting(key, value, origin); + collected.put(key, setting); } - public void putAll(Map keysAndValues) { + public void putAll(Map keysAndValues, ConfigOrigin origin) { // attempt merge+replace to avoid collector seeing partial update - Map merged = new ConcurrentHashMap<>(keysAndValues); - merged.replaceAll(ConfigCollector::filterConfigEntry); + Map merged = + new ConcurrentHashMap<>(keysAndValues.size() + collected.size()); + for (Map.Entry entry : keysAndValues.entrySet()) { + ConfigSetting setting = new ConfigSetting(entry.getKey(), entry.getValue(), origin); + merged.put(entry.getKey(), setting); + } while (true) { - Map current = collected; + Map current = collected; current.forEach(merged::putIfAbsent); if (COLLECTED_UPDATER.compareAndSet(this, current, merged)) { break; // success @@ -50,15 +47,11 @@ public void putAll(Map keysAndValues) { } @SuppressWarnings("unchecked") - public Map collect() { + public Map collect() { if (!collected.isEmpty()) { return COLLECTED_UPDATER.getAndSet(this, new ConcurrentHashMap<>()); } else { return Collections.emptyMap(); } } - - private static Object filterConfigEntry(String key, Object value) { - return CONFIG_FILTER_LIST.contains(key) ? "" : value; - } } diff --git a/internal-api/src/main/java/datadog/trace/api/ConfigOrigin.java b/internal-api/src/main/java/datadog/trace/api/ConfigOrigin.java new file mode 100644 index 00000000000..3f46985758a --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/ConfigOrigin.java @@ -0,0 +1,18 @@ +package datadog.trace.api; + +public enum ConfigOrigin { + /** configurations that are set through environment variables */ + ENV("env_var"), + /** values that are set using remote config */ + REMOTE("remote_config"), + /** configurations that are set through JVM properties */ + JVM_PROP("jvm_prop"), + /** set when the user has not set any configuration for the key (defaults to a value) */ + DEFAULT("default"); + + public final String value; + + ConfigOrigin(String value) { + this.value = value; + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/ConfigSetting.java b/internal-api/src/main/java/datadog/trace/api/ConfigSetting.java new file mode 100644 index 00000000000..ac4471fbae1 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/ConfigSetting.java @@ -0,0 +1,52 @@ +package datadog.trace.api; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; + +public final class ConfigSetting { + public final String key; + public final Object value; + public final ConfigOrigin origin; + + private static final Set CONFIG_FILTER_LIST = + new HashSet<>( + Arrays.asList("DD_API_KEY", "dd.api-key", "dd.profiling.api-key", "dd.profiling.apikey")); + + private static Object filterConfigEntry(String key, Object value) { + return CONFIG_FILTER_LIST.contains(key) ? "" : value; + } + + public ConfigSetting(String key, Object value, ConfigOrigin origin) { + this.key = key; + this.value = filterConfigEntry(key, value); + this.origin = origin; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ConfigSetting that = (ConfigSetting) o; + return key.equals(that.key) && Objects.equals(value, that.value) && origin == that.origin; + } + + @Override + public int hashCode() { + return Objects.hash(key, value, origin); + } + + @Override + public String toString() { + return "ConfigSetting{" + + "key='" + + key + + '\'' + + ", value=" + + value + + ", origin=" + + origin + + '}'; + } +} diff --git a/internal-api/src/main/java/datadog/trace/api/DynamicConfig.java b/internal-api/src/main/java/datadog/trace/api/DynamicConfig.java index 6d5978604c5..23fbe76d8f5 100644 --- a/internal-api/src/main/java/datadog/trace/api/DynamicConfig.java +++ b/internal-api/src/main/java/datadog/trace/api/DynamicConfig.java @@ -246,7 +246,7 @@ static void reportConfigChange(Snapshot newSnapshot) { maybePut(update, TRACE_SAMPLE_RATE, newSnapshot.traceSampleRate); - ConfigCollector.get().putAll(update); + ConfigCollector.get().putAll(update, ConfigOrigin.REMOTE); } @SuppressWarnings("SameParameterValue") diff --git a/internal-api/src/main/java/datadog/trace/api/env/CapturedEnvironment.java b/internal-api/src/main/java/datadog/trace/api/env/CapturedEnvironment.java index a61b892447e..5bbb2744531 100644 --- a/internal-api/src/main/java/datadog/trace/api/env/CapturedEnvironment.java +++ b/internal-api/src/main/java/datadog/trace/api/env/CapturedEnvironment.java @@ -8,7 +8,7 @@ /** * The {@code CapturedEnvironment} instance keeps those {@code Config} values which are platform - * dependant. Notice that this class must be consider internal. You should not depend on it + * dependant. Notice that this class must be considered internal. You should not depend on it * directly. */ public class CapturedEnvironment { diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/config/provider/CapturedEnvironmentConfigSource.java b/internal-api/src/main/java/datadog/trace/bootstrap/config/provider/CapturedEnvironmentConfigSource.java index f1d9175a47d..56bfb0ad547 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/config/provider/CapturedEnvironmentConfigSource.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/config/provider/CapturedEnvironmentConfigSource.java @@ -1,5 +1,6 @@ package datadog.trace.bootstrap.config.provider; +import datadog.trace.api.ConfigOrigin; import datadog.trace.api.env.CapturedEnvironment; public final class CapturedEnvironmentConfigSource extends ConfigProvider.Source { @@ -17,4 +18,9 @@ public CapturedEnvironmentConfigSource(CapturedEnvironment env) { protected String get(String key) { return env.getProperties().get(key); } + + @Override + public ConfigOrigin origin() { + return ConfigOrigin.ENV; + } } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/config/provider/ConfigConverter.java b/internal-api/src/main/java/datadog/trace/bootstrap/config/provider/ConfigConverter.java index 1bd9422087f..fe431f15313 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/config/provider/ConfigConverter.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/config/provider/ConfigConverter.java @@ -363,4 +363,30 @@ protected MethodHandle computeValue(Class type) { } } } + + public static String renderIntegerRange(BitSet bitset) { + StringBuilder sb = new StringBuilder(); + int start = 0; + while (true) { + start = bitset.nextSetBit(start); + if (start < 0) { + break; + } + int end = bitset.nextClearBit(start); + if (sb.length() > 0) { + sb.append(','); + } + if (start < end - 1) { + // interval + sb.append(start); + sb.append('-'); + sb.append(end); + } else { + // single value + sb.append(start); + } + start = end; + } + return sb.toString(); + } } 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 9638d498d8c..4d776da370c 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 @@ -3,6 +3,7 @@ import static datadog.trace.api.config.GeneralConfig.CONFIGURATION_FILE; import datadog.trace.api.ConfigCollector; +import datadog.trace.api.ConfigOrigin; import de.thetaphi.forbiddenapis.SuppressForbidden; import java.io.File; import java.io.FileNotFoundException; @@ -64,6 +65,9 @@ public > T getEnum(String key, Class enumType, T defaultVal log.debug("failed to parse {} for {}, defaulting to {}", value, key, defaultValue); } } + if (collectConfig) { + ConfigCollector.get().put(key, String.valueOf(defaultValue), ConfigOrigin.DEFAULT); + } return defaultValue; } @@ -72,11 +76,14 @@ public String getString(String key, String defaultValue, String... aliases) { String value = source.get(key, aliases); if (value != null) { if (collectConfig) { - ConfigCollector.get().put(key, value); + ConfigCollector.get().put(key, value, source.origin()); } return value; } } + if (collectConfig && defaultValue != null) { + ConfigCollector.get().put(key, defaultValue, ConfigOrigin.DEFAULT); + } return defaultValue; } @@ -89,11 +96,14 @@ public String getStringNotEmpty(String key, String defaultValue, String... alias String value = source.get(key, aliases); if (value != null && !value.trim().isEmpty()) { if (collectConfig) { - ConfigCollector.get().put(key, value); + ConfigCollector.get().put(key, value, source.origin()); } return value; } } + if (collectConfig && defaultValue != null) { + ConfigCollector.get().put(key, defaultValue, ConfigOrigin.DEFAULT); + } return defaultValue; } @@ -110,11 +120,14 @@ public String getStringExcludingSource( String value = source.get(key, aliases); if (value != null) { if (collectConfig) { - ConfigCollector.get().put(key, value); + ConfigCollector.get().put(key, value, source.origin()); } return value; } } + if (collectConfig && defaultValue != null) { + ConfigCollector.get().put(key, defaultValue, ConfigOrigin.DEFAULT); + } return defaultValue; } @@ -182,7 +195,7 @@ private T get(String key, T defaultValue, Class type, String... aliases) T value = ConfigConverter.valueOf(sourceValue, type); if (value != null) { if (collectConfig) { - ConfigCollector.get().put(key, sourceValue); + ConfigCollector.get().put(key, sourceValue, source.origin()); } return value; } @@ -190,6 +203,9 @@ private T get(String key, T defaultValue, Class type, String... aliases) // continue } } + if (collectConfig && defaultValue != null) { + ConfigCollector.get().put(key, defaultValue, ConfigOrigin.DEFAULT); + } return defaultValue; } @@ -200,6 +216,9 @@ public List getList(String key) { public List getList(String key, List defaultValue) { String list = getString(key); if (null == list) { + if (collectConfig && defaultValue != null) { + ConfigCollector.get().put(key, String.join(",", defaultValue), ConfigOrigin.DEFAULT); + } return defaultValue; } else { return ConfigConverter.parseList(getString(key)); @@ -209,6 +228,10 @@ public List getList(String key, List defaultValue) { public Set getSet(String key, Set defaultValue) { String list = getString(key); if (null == list) { + if (collectConfig && defaultValue != null) { + String defaultValueStr = String.join(",", defaultValue); + ConfigCollector.get().put(key, defaultValueStr, ConfigOrigin.DEFAULT); + } return defaultValue; } else { return new HashSet(ConfigConverter.parseList(getString(key))); @@ -221,35 +244,46 @@ public List getSpacedList(String key) { public Map getMergedMap(String key) { Map merged = new HashMap<>(); + ConfigOrigin origin = ConfigOrigin.DEFAULT; // System properties take precedence over env // prior art: // https://docs.spring.io/spring-boot/docs/1.5.6.RELEASE/reference/html/boot-features-external-config.html // We reverse iterate to allow overrides for (int i = sources.length - 1; 0 <= i; i--) { String value = sources[i].get(key); - merged.putAll(ConfigConverter.parseMap(value, key)); + Map parsedMap = ConfigConverter.parseMap(value, key); + if (!parsedMap.isEmpty()) { + origin = sources[i].origin(); + } + merged.putAll(parsedMap); } - collectMapSetting(key, merged); + collectMapSetting(key, merged, origin); return merged; } public Map getOrderedMap(String key) { LinkedHashMap merged = new LinkedHashMap<>(); + ConfigOrigin origin = ConfigOrigin.DEFAULT; // System properties take precedence over env // prior art: // https://docs.spring.io/spring-boot/docs/1.5.6.RELEASE/reference/html/boot-features-external-config.html // We reverse iterate to allow overrides for (int i = sources.length - 1; 0 <= i; i--) { String value = sources[i].get(key); - merged.putAll(ConfigConverter.parseOrderedMap(value, key)); + Map parsedMap = ConfigConverter.parseOrderedMap(value, key); + if (!parsedMap.isEmpty()) { + origin = sources[i].origin(); + } + merged.putAll(parsedMap); } - collectMapSetting(key, merged); + collectMapSetting(key, merged, origin); return merged; } public Map getMergedMapWithOptionalMappings( String defaultPrefix, boolean lowercaseKeys, String... keys) { Map merged = new HashMap<>(); + ConfigOrigin origin = ConfigOrigin.DEFAULT; // System properties take precedence over env // prior art: // https://docs.spring.io/spring-boot/docs/1.5.6.RELEASE/reference/html/boot-features-external-config.html @@ -257,10 +291,14 @@ public Map getMergedMapWithOptionalMappings( for (String key : keys) { for (int i = sources.length - 1; 0 <= i; i--) { String value = sources[i].get(key); - merged.putAll( - ConfigConverter.parseMapWithOptionalMappings(value, key, defaultPrefix, lowercaseKeys)); + Map parsedMap = + ConfigConverter.parseMapWithOptionalMappings(value, key, defaultPrefix, lowercaseKeys); + if (!parsedMap.isEmpty()) { + origin = sources[i].origin(); + } + merged.putAll(parsedMap); } - collectMapSetting(key, merged); + collectMapSetting(key, merged, origin); } return merged; } @@ -268,11 +306,17 @@ public Map getMergedMapWithOptionalMappings( public BitSet getIntegerRange(final String key, final BitSet defaultValue) { final String value = getString(key); try { - return value == null ? defaultValue : ConfigConverter.parseIntegerRangeSet(value, key); + if (value != null) { + return ConfigConverter.parseIntegerRangeSet(value, key); + } } catch (final NumberFormatException e) { log.warn("Invalid configuration for {}", key, e); - return defaultValue; } + if (collectConfig) { + String defaultValueStr = ConfigConverter.renderIntegerRange(defaultValue); + ConfigCollector.get().put(key, defaultValueStr, ConfigOrigin.DEFAULT); + } + return defaultValue; } public boolean isEnabled( @@ -363,7 +407,7 @@ public static ConfigProvider withPropertiesOverride(Properties properties) { } } - private void collectMapSetting(String key, Map merged) { + private void collectMapSetting(String key, Map merged, ConfigOrigin origin) { if (!collectConfig || merged.isEmpty()) { return; } @@ -376,9 +420,7 @@ private void collectMapSetting(String key, Map merged) { mergedValue.append(':'); mergedValue.append(entry.getValue()); } - if (mergedValue.length() > 0) { - ConfigCollector.get().put(key, mergedValue.toString()); - } + ConfigCollector.get().put(key, mergedValue.toString(), origin); } /** @@ -439,5 +481,7 @@ public final String get(String key, String... aliases) { } protected abstract String get(String key); + + public abstract ConfigOrigin origin(); } } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/config/provider/EnvironmentConfigSource.java b/internal-api/src/main/java/datadog/trace/bootstrap/config/provider/EnvironmentConfigSource.java index c69caf719fc..6719693fd86 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/config/provider/EnvironmentConfigSource.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/config/provider/EnvironmentConfigSource.java @@ -2,10 +2,17 @@ import static datadog.trace.util.Strings.propertyNameToEnvironmentVariableName; +import datadog.trace.api.ConfigOrigin; + final class EnvironmentConfigSource extends ConfigProvider.Source { @Override protected String get(String key) { return System.getenv(propertyNameToEnvironmentVariableName(key)); } + + @Override + public ConfigOrigin origin() { + return ConfigOrigin.ENV; + } } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/config/provider/PropertiesConfigSource.java b/internal-api/src/main/java/datadog/trace/bootstrap/config/provider/PropertiesConfigSource.java index 9f5e77a9cbe..a3391a54ef6 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/config/provider/PropertiesConfigSource.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/config/provider/PropertiesConfigSource.java @@ -2,6 +2,7 @@ import static datadog.trace.util.Strings.propertyNameToSystemPropertyName; +import datadog.trace.api.ConfigOrigin; import java.util.Properties; final class PropertiesConfigSource extends ConfigProvider.Source { @@ -25,4 +26,9 @@ public String getConfigFileStatus() { protected String get(String key) { return props.getProperty(useSystemPropertyFormat ? propertyNameToSystemPropertyName(key) : key); } + + @Override + public ConfigOrigin origin() { + return ConfigOrigin.JVM_PROP; + } } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/config/provider/SystemPropertiesConfigSource.java b/internal-api/src/main/java/datadog/trace/bootstrap/config/provider/SystemPropertiesConfigSource.java index fc6dba8c6f5..08aaf487027 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/config/provider/SystemPropertiesConfigSource.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/config/provider/SystemPropertiesConfigSource.java @@ -2,10 +2,17 @@ import static datadog.trace.util.Strings.propertyNameToSystemPropertyName; +import datadog.trace.api.ConfigOrigin; + public final class SystemPropertiesConfigSource extends ConfigProvider.Source { @Override protected String get(String key) { return System.getProperty(propertyNameToSystemPropertyName(key)); } + + @Override + public ConfigOrigin origin() { + return ConfigOrigin.JVM_PROP; + } } diff --git a/internal-api/src/main/java/datadog/trace/util/AgentThreadFactory.java b/internal-api/src/main/java/datadog/trace/util/AgentThreadFactory.java index 7eff0a89217..0973bf2f4c5 100644 --- a/internal-api/src/main/java/datadog/trace/util/AgentThreadFactory.java +++ b/internal-api/src/main/java/datadog/trace/util/AgentThreadFactory.java @@ -104,7 +104,7 @@ public static Thread newAgentThread( @Override public void uncaughtException(final Thread thread, final Throwable e) { LoggerFactory.getLogger(runnable.getClass()) - .error("Uncaught exception in {}", agentThread.threadName, e); + .error("Uncaught exception {} in {}", e, agentThread.threadName, e); } }); return thread; diff --git a/internal-api/src/test/groovy/datadog/trace/api/ConfigCollectorTest.groovy b/internal-api/src/test/groovy/datadog/trace/api/ConfigCollectorTest.groovy index 2b7b0d709f5..e3df13c7cd6 100644 --- a/internal-api/src/test/groovy/datadog/trace/api/ConfigCollectorTest.groovy +++ b/internal-api/src/test/groovy/datadog/trace/api/ConfigCollectorTest.groovy @@ -8,9 +8,16 @@ import datadog.trace.api.config.JmxFetchConfig import datadog.trace.api.config.TraceInstrumentationConfig import datadog.trace.api.config.TracerConfig import datadog.trace.api.iast.telemetry.Verbosity +import datadog.trace.api.naming.SpanNaming +import datadog.trace.bootstrap.config.provider.ConfigConverter import datadog.trace.test.util.DDSpecification import datadog.trace.util.Strings +import static datadog.trace.api.ConfigDefaults.DEFAULT_HTTP_CLIENT_ERROR_STATUSES +import static datadog.trace.api.ConfigDefaults.DEFAULT_IAST_WEAK_HASH_ALGORITHMS +import static datadog.trace.api.ConfigDefaults.DEFAULT_TELEMETRY_HEARTBEAT_INTERVAL +import static datadog.trace.api.UserEventTrackingMode.SAFE + class ConfigCollectorTest extends DDSpecification { def "non-default config settings get collected"() { @@ -18,7 +25,9 @@ class ConfigCollectorTest extends DDSpecification { injectEnvConfig(Strings.toEnvVar(configKey), configValue) expect: - ConfigCollector.get().collect().get(configKey) == configValue + def setting = ConfigCollector.get().collect().get(configKey) + setting.value == configValue + setting.origin == ConfigOrigin.ENV where: configKey | configValue @@ -53,7 +62,7 @@ class ConfigCollectorTest extends DDSpecification { // ConfigProvider.getMergedMapWithOptionalMappings TracerConfig.HEADER_TAGS | "e:five" // ConfigProvider.getIntegerRange - TracerConfig.HTTP_CLIENT_ERROR_STATUSES | "1:1" + TracerConfig.HTTP_CLIENT_ERROR_STATUSES | "400-402" } def "should collect merged data from multiple sources"() { @@ -62,7 +71,9 @@ class ConfigCollectorTest extends DDSpecification { injectSysConfig(configKey, configValue2) expect: - ConfigCollector.get().collect().get(configKey) == expectedValue + def setting = ConfigCollector.get().collect().get(configKey) + setting.value == expectedValue + setting.origin == ConfigOrigin.JVM_PROP where: configKey | configValue1 | configValue2 | expectedValue @@ -74,27 +85,38 @@ class ConfigCollectorTest extends DDSpecification { TracerConfig.HEADER_TAGS | "j:ten" | "e:five,b:six" | "e:five,j:ten,b:six" } - def "default config settings are NOT collected"() { + def "default not-null config settings are collected"() { + expect: + def setting = ConfigCollector.get().collect().get(configKey) + setting.origin == ConfigOrigin.DEFAULT + setting.value == defaultValue + + where: + configKey | defaultValue + IastConfig.IAST_TELEMETRY_VERBOSITY | Verbosity.INFORMATION.toString() + TracerConfig.TRACE_SPAN_ATTRIBUTE_SCHEMA | "v" + SpanNaming.SCHEMA_MIN_VERSION + AppSecConfig.APPSEC_AUTOMATED_USER_EVENTS_TRACKING | SAFE.toString() + GeneralConfig.TELEMETRY_HEARTBEAT_INTERVAL | DEFAULT_TELEMETRY_HEARTBEAT_INTERVAL + CiVisibilityConfig.CIVISIBILITY_JACOCO_GRADLE_SOURCE_SETS | "main" + IastConfig.IAST_WEAK_HASH_ALGORITHMS | DEFAULT_IAST_WEAK_HASH_ALGORITHMS.join(",") + TracerConfig.HTTP_CLIENT_ERROR_STATUSES | ConfigConverter.renderIntegerRange(DEFAULT_HTTP_CLIENT_ERROR_STATUSES) + } + + def "default NULL config settings are NOT collected"() { expect: - !ConfigCollector.get().collect().containsKey(configKey) + ConfigCollector.get().collect().get(configKey) == null where: configKey | _ - IastConfig.IAST_TELEMETRY_VERBOSITY | _ - TracerConfig.TRACE_SPAN_ATTRIBUTE_SCHEMA | _ - AppSecConfig.APPSEC_AUTOMATED_USER_EVENTS_TRACKING | _ GeneralConfig.APPLICATION_KEY | _ TraceInstrumentationConfig.RESOLVER_USE_URL_CACHES | _ JmxFetchConfig.JMX_FETCH_CHECK_PERIOD | _ CiVisibilityConfig.CIVISIBILITY_MODULE_ID | _ - GeneralConfig.TELEMETRY_HEARTBEAT_INTERVAL | _ TracerConfig.TRACE_SAMPLE_RATE | _ TraceInstrumentationConfig.JMS_PROPAGATION_DISABLED_TOPICS | _ - IastConfig.IAST_WEAK_HASH_ALGORITHMS | _ TracerConfig.PROXY_NO_PROXY | _ TracerConfig.TRACE_PEER_SERVICE_MAPPING | _ TracerConfig.TRACE_HTTP_SERVER_PATH_RESOURCE_NAME_MAPPING | _ TracerConfig.HEADER_TAGS | _ - TracerConfig.HTTP_CLIENT_ERROR_STATUSES | _ } } diff --git a/internal-api/src/test/groovy/datadog/trace/api/ConfigSettingTest.groovy b/internal-api/src/test/groovy/datadog/trace/api/ConfigSettingTest.groovy new file mode 100644 index 00000000000..fa2d17230b5 --- /dev/null +++ b/internal-api/src/test/groovy/datadog/trace/api/ConfigSettingTest.groovy @@ -0,0 +1,45 @@ +package datadog.trace.api + +import spock.lang.Specification + +class ConfigSettingTest extends Specification { + + def "supports equality check"() { + when: + def cs1 = new ConfigSetting(key1, value1, origin1) + def cs2 = new ConfigSetting(key2, value2, origin2) + + then: + if (key1 == key2 && value1 == value2 && origin1 == origin2) { + assert cs1.hashCode() == cs2.hashCode() + assert cs1 == cs2 + assert cs2 == cs1 + assert cs1.toString() == cs2.toString() + } else { + assert cs1.hashCode() != cs2.hashCode() + assert cs1 != cs2 + assert cs2 != cs1 + assert cs1.toString() != cs2.toString() + } + + where: + key1 | key2 | value1 | value2 | origin1 | origin2 + "key" | "key" | "value" | "value" | ConfigOrigin.DEFAULT | ConfigOrigin.DEFAULT + "key" | "key2" | "value" | "value" | ConfigOrigin.ENV | ConfigOrigin.ENV + "key" | "key" | "value2" | "value" | ConfigOrigin.JVM_PROP | ConfigOrigin.JVM_PROP + "key" | "key" | "value" | "value" | ConfigOrigin.ENV | ConfigOrigin.DEFAULT + } + + def "filters key values"() { + expect: + new ConfigSetting(key, value, ConfigOrigin.DEFAULT).value == filteredValue + + where: + key | value | filteredValue + "DD_API_KEY" | "somevalue" | "" + "dd.api-key" | "somevalue" | "" + "dd.profiling.api-key" | "somevalue" | "" + "dd.profiling.apikey" | "somevalue" | "" + "some.other.key" | "somevalue" | "somevalue" + } +} diff --git a/internal-api/src/test/groovy/datadog/trace/api/telemetry/TelemetryCollectorsTest.groovy b/internal-api/src/test/groovy/datadog/trace/api/telemetry/TelemetryCollectorsTest.groovy index bebe8fe1ecd..c8db7b8953c 100644 --- a/internal-api/src/test/groovy/datadog/trace/api/telemetry/TelemetryCollectorsTest.groovy +++ b/internal-api/src/test/groovy/datadog/trace/api/telemetry/TelemetryCollectorsTest.groovy @@ -1,6 +1,8 @@ package datadog.trace.api.telemetry import datadog.trace.api.ConfigCollector +import datadog.trace.api.ConfigOrigin +import datadog.trace.api.ConfigSetting import datadog.trace.api.IntegrationsCollector import datadog.trace.api.metrics.SpanMetricRegistryImpl import datadog.trace.test.util.DDSpecification @@ -35,12 +37,17 @@ class TelemetryCollectorsTest extends DDSpecification { ConfigCollector.get().collect() when: - ConfigCollector.get().put('key1', 'value1') - ConfigCollector.get().put('key2', 'value2') - ConfigCollector.get().put('key1', 'replaced') + ConfigCollector.get().put('key1', 'value1', ConfigOrigin.DEFAULT) + ConfigCollector.get().put('key2', 'value2', ConfigOrigin.ENV) + ConfigCollector.get().put('key1', 'replaced', ConfigOrigin.REMOTE) + ConfigCollector.get().put('key3', 'value3', ConfigOrigin.JVM_PROP) then: - ConfigCollector.get().collect() == [key1: 'replaced', key2: 'value2'] + ConfigCollector.get().collect().values().toSet() == [ + new ConfigSetting('key1', 'replaced', ConfigOrigin.REMOTE), + new ConfigSetting('key2', 'value2', ConfigOrigin.ENV), + new ConfigSetting('key3', 'value3', ConfigOrigin.JVM_PROP) + ] as Set } def "no metrics - drain empty list"() { @@ -183,10 +190,10 @@ class TelemetryCollectorsTest extends DDSpecification { ConfigCollector.get().collect() when: - ConfigCollector.get().put('DD_API_KEY', 'sensitive data') + ConfigCollector.get().put('DD_API_KEY', 'sensitive data', ConfigOrigin.ENV) then: - ConfigCollector.get().collect().get('DD_API_KEY') == '' + ConfigCollector.get().collect().get('DD_API_KEY').value == '' } def "update-drain span core metrics"() { diff --git a/internal-api/src/test/groovy/datadog/trace/bootstrap/config/provider/ConfigConverterTest.groovy b/internal-api/src/test/groovy/datadog/trace/bootstrap/config/provider/ConfigConverterTest.groovy index e5406ac0f55..6ef1172f385 100644 --- a/internal-api/src/test/groovy/datadog/trace/bootstrap/config/provider/ConfigConverterTest.groovy +++ b/internal-api/src/test/groovy/datadog/trace/bootstrap/config/provider/ConfigConverterTest.groovy @@ -98,4 +98,31 @@ class ConfigConverterTest extends DDSpecification { "====" | '=' | [:] // spotless:on } + + def "render a single interval bitset"() { + def set = new BitSet() + set.set(200, 299) + + expect: + ConfigConverter.renderIntegerRange(set) == "200-299" + } + + def "render a single value bitset"() { + def set = new BitSet() + set.set(33) + + expect: + ConfigConverter.renderIntegerRange(set) == "33" + } + + def "render bitset intervals"() { + def set = new BitSet() + set.set(33) + set.set(200, 300) + set.set(303) + set.set(400, 500) + + expect: + ConfigConverter.renderIntegerRange(set) == "33,200-300,303,400-500" + } } diff --git a/telemetry/src/main/java/datadog/telemetry/BufferedEvents.java b/telemetry/src/main/java/datadog/telemetry/BufferedEvents.java new file mode 100644 index 00000000000..3fc437b4de0 --- /dev/null +++ b/telemetry/src/main/java/datadog/telemetry/BufferedEvents.java @@ -0,0 +1,137 @@ +package datadog.telemetry; + +import datadog.telemetry.api.DistributionSeries; +import datadog.telemetry.api.Integration; +import datadog.telemetry.api.LogMessage; +import datadog.telemetry.api.Metric; +import datadog.telemetry.dependency.Dependency; +import datadog.trace.api.ConfigSetting; +import java.util.ArrayList; + +/** + * Keeps track of attempted to send telemetry events that can be used as a source for the next + * telemetry request attempt or telemetry metric calculation. + */ +public final class BufferedEvents implements EventSource, EventSink { + private static final int INITIAL_CAPACITY = 32; + private ArrayList configChangeEvents; + private int configChangeIndex; + private ArrayList integrationEvents; + private int integrationIndex; + private ArrayList dependencyEvents; + private int dependencyIndex; + private ArrayList metricEvents; + private int metricIndex; + private ArrayList distributionSeriesEvents; + private int distributionSeriesIndex; + private ArrayList logMessageEvents; + private int logMessageIndex; + + public void addConfigChangeEvent(ConfigSetting event) { + if (configChangeEvents == null) { + configChangeEvents = new ArrayList<>(INITIAL_CAPACITY); + } + configChangeEvents.add(event); + } + + @Override + public void addIntegrationEvent(Integration event) { + if (integrationEvents == null) { + integrationEvents = new ArrayList<>(INITIAL_CAPACITY); + } + integrationEvents.add(event); + } + + @Override + public void addDependencyEvent(Dependency event) { + if (dependencyEvents == null) { + dependencyEvents = new ArrayList<>(INITIAL_CAPACITY); + } + dependencyEvents.add(event); + } + + @Override + public void addMetricEvent(Metric event) { + if (metricEvents == null) { + metricEvents = new ArrayList<>(INITIAL_CAPACITY); + } + metricEvents.add(event); + } + + @Override + public void addDistributionSeriesEvent(DistributionSeries event) { + if (distributionSeriesEvents == null) { + distributionSeriesEvents = new ArrayList<>(INITIAL_CAPACITY); + } + distributionSeriesEvents.add(event); + } + + @Override + public void addLogMessageEvent(LogMessage event) { + if (logMessageEvents == null) { + logMessageEvents = new ArrayList<>(INITIAL_CAPACITY); + } + logMessageEvents.add(event); + } + + @Override + public boolean hasConfigChangeEvent() { + return configChangeEvents != null && configChangeIndex < configChangeEvents.size(); + } + + @Override + public ConfigSetting nextConfigChangeEvent() { + return configChangeEvents.get(configChangeIndex++); + } + + @Override + public boolean hasIntegrationEvent() { + return integrationEvents != null && integrationIndex < integrationEvents.size(); + } + + @Override + public Integration nextIntegrationEvent() { + return integrationEvents.get(integrationIndex++); + } + + @Override + public boolean hasDependencyEvent() { + return dependencyEvents != null && dependencyIndex < dependencyEvents.size(); + } + + @Override + public Dependency nextDependencyEvent() { + return dependencyEvents.get(dependencyIndex++); + } + + @Override + public boolean hasMetricEvent() { + return metricEvents != null && metricIndex < metricEvents.size(); + } + + @Override + public Metric nextMetricEvent() { + return metricEvents.get(metricIndex++); + } + + @Override + public boolean hasDistributionSeriesEvent() { + return distributionSeriesEvents != null + && distributionSeriesIndex < distributionSeriesEvents.size(); + } + + @Override + public DistributionSeries nextDistributionSeriesEvent() { + return distributionSeriesEvents.get(distributionSeriesIndex++); + } + + @Override + public boolean hasLogMessageEvent() { + return logMessageEvents != null && logMessageIndex < logMessageEvents.size(); + } + + @Override + public LogMessage nextLogMessageEvent() { + return logMessageEvents.get(logMessageIndex++); + } +} diff --git a/telemetry/src/main/java/datadog/telemetry/EventSink.java b/telemetry/src/main/java/datadog/telemetry/EventSink.java new file mode 100644 index 00000000000..767845d9f04 --- /dev/null +++ b/telemetry/src/main/java/datadog/telemetry/EventSink.java @@ -0,0 +1,51 @@ +package datadog.telemetry; + +import datadog.telemetry.api.DistributionSeries; +import datadog.telemetry.api.Integration; +import datadog.telemetry.api.LogMessage; +import datadog.telemetry.api.Metric; +import datadog.telemetry.dependency.Dependency; +import datadog.trace.api.ConfigSetting; + +/** + * A unified interface for telemetry event sink. It is used to buffer events polled from the queues + * to reattempt next sending attempt. A NOOP implementation is used to discard event on the next + * failing attempt. + */ +interface EventSink { + void addConfigChangeEvent(ConfigSetting event); + + void addIntegrationEvent(Integration event); + + void addDependencyEvent(Dependency event); + + void addMetricEvent(Metric event); + + void addDistributionSeriesEvent(DistributionSeries event); + + void addLogMessageEvent(LogMessage event); + + EventSink NOOP = new Noop(); + + class Noop implements EventSink { + private Noop() {} + + @Override + public void addConfigChangeEvent(ConfigSetting event) {} + + @Override + public void addIntegrationEvent(Integration event) {} + + @Override + public void addDependencyEvent(Dependency event) {} + + @Override + public void addMetricEvent(Metric event) {} + + @Override + public void addDistributionSeriesEvent(DistributionSeries event) {} + + @Override + public void addLogMessageEvent(LogMessage event) {} + } +} diff --git a/telemetry/src/main/java/datadog/telemetry/EventSource.java b/telemetry/src/main/java/datadog/telemetry/EventSource.java new file mode 100644 index 00000000000..3234edb391f --- /dev/null +++ b/telemetry/src/main/java/datadog/telemetry/EventSource.java @@ -0,0 +1,133 @@ +package datadog.telemetry; + +import datadog.telemetry.api.DistributionSeries; +import datadog.telemetry.api.Integration; +import datadog.telemetry.api.LogMessage; +import datadog.telemetry.api.Metric; +import datadog.telemetry.dependency.Dependency; +import datadog.trace.api.ConfigSetting; +import java.util.Queue; + +/** + * A unified interface for telemetry event source. There are two types of event sources: - Queued - + * a primary telemetry event source - BufferedEvents - events taken from the primary source and + * buffered for a retry + */ +interface EventSource { + boolean hasConfigChangeEvent(); + + ConfigSetting nextConfigChangeEvent(); + + boolean hasIntegrationEvent(); + + Integration nextIntegrationEvent(); + + boolean hasDependencyEvent(); + + Dependency nextDependencyEvent(); + + boolean hasMetricEvent(); + + Metric nextMetricEvent(); + + boolean hasDistributionSeriesEvent(); + + DistributionSeries nextDistributionSeriesEvent(); + + boolean hasLogMessageEvent(); + + LogMessage nextLogMessageEvent(); + + default boolean isEmpty() { + return !hasConfigChangeEvent() + && !hasIntegrationEvent() + && !hasDependencyEvent() + && !hasMetricEvent() + && !hasDistributionSeriesEvent() + && !hasLogMessageEvent(); + } + + final class Queued implements EventSource { + private final Queue configChangeQueue; + private final Queue integrationQueue; + private final Queue dependencyQueue; + private final Queue metricQueue; + private final Queue distributionSeriesQueue; + private final Queue logMessageQueue; + + Queued( + Queue configChangeQueue, + Queue integrationQueue, + Queue dependencyQueue, + Queue metricQueue, + Queue distributionSeriesQueue, + Queue logMessageQueue) { + this.configChangeQueue = configChangeQueue; + this.integrationQueue = integrationQueue; + this.dependencyQueue = dependencyQueue; + this.metricQueue = metricQueue; + this.distributionSeriesQueue = distributionSeriesQueue; + this.logMessageQueue = logMessageQueue; + } + + @Override + public boolean hasConfigChangeEvent() { + return !configChangeQueue.isEmpty(); + } + + @Override + public ConfigSetting nextConfigChangeEvent() { + return configChangeQueue.poll(); + } + + @Override + public boolean hasIntegrationEvent() { + return !integrationQueue.isEmpty(); + } + + @Override + public Integration nextIntegrationEvent() { + return integrationQueue.poll(); + } + + @Override + public boolean hasDependencyEvent() { + return !dependencyQueue.isEmpty(); + } + + @Override + public Dependency nextDependencyEvent() { + return dependencyQueue.poll(); + } + + @Override + public boolean hasMetricEvent() { + return !metricQueue.isEmpty(); + } + + @Override + public Metric nextMetricEvent() { + return metricQueue.poll(); + } + + @Override + public boolean hasDistributionSeriesEvent() { + return !distributionSeriesQueue.isEmpty(); + } + + @Override + public DistributionSeries nextDistributionSeriesEvent() { + return distributionSeriesQueue.poll(); + } + + @Override + public boolean hasLogMessageEvent() { + return !logMessageQueue.isEmpty(); + } + + @Override + public LogMessage nextLogMessageEvent() { + return logMessageQueue.poll(); + } + } +} diff --git a/telemetry/src/main/java/datadog/telemetry/ExtendedHeartbeatData.java b/telemetry/src/main/java/datadog/telemetry/ExtendedHeartbeatData.java new file mode 100644 index 00000000000..6d559019cc0 --- /dev/null +++ b/telemetry/src/main/java/datadog/telemetry/ExtendedHeartbeatData.java @@ -0,0 +1,114 @@ +package datadog.telemetry; + +import datadog.telemetry.api.DistributionSeries; +import datadog.telemetry.api.Integration; +import datadog.telemetry.api.LogMessage; +import datadog.telemetry.api.Metric; +import datadog.telemetry.dependency.Dependency; +import datadog.trace.api.ConfigSetting; +import java.util.ArrayList; + +public class ExtendedHeartbeatData { + private static final int DEFAULT_DEPENDENCIES_LIMIT = 2000; + private static final int INITIAL_CAPACITY = 32; + + private final int dependenciesLimit; + private final ArrayList configuration; + private final ArrayList dependencies; + private final ArrayList integrations; + + public ExtendedHeartbeatData() { + this(DEFAULT_DEPENDENCIES_LIMIT); + } + + ExtendedHeartbeatData(int dependenciesLimit) { + this.dependenciesLimit = dependenciesLimit; + configuration = new ArrayList<>(INITIAL_CAPACITY); + dependencies = new ArrayList<>(INITIAL_CAPACITY); + integrations = new ArrayList<>(INITIAL_CAPACITY); + } + + public void pushConfigSetting(ConfigSetting cs) { + configuration.add(cs); + } + + public void pushDependency(Dependency d) { + if (dependencies.size() < dependenciesLimit) { + dependencies.add(d); + } + } + + public void pushIntegration(Integration i) { + integrations.add(i); + } + + public EventSource snapshot() { + return new Snapshot(); + } + + private final class Snapshot implements EventSource { + private int configIndex; + private int dependencyIndex; + private int integrationIndex; + + @Override + public boolean hasConfigChangeEvent() { + return configIndex < configuration.size(); + } + + @Override + public ConfigSetting nextConfigChangeEvent() { + return configuration.get(configIndex++); + } + + @Override + public boolean hasIntegrationEvent() { + return integrationIndex < integrations.size(); + } + + @Override + public Integration nextIntegrationEvent() { + return integrations.get(integrationIndex++); + } + + @Override + public boolean hasDependencyEvent() { + return dependencyIndex < dependencies.size(); + } + + @Override + public Dependency nextDependencyEvent() { + return dependencies.get(dependencyIndex++); + } + + @Override + public boolean hasMetricEvent() { + return false; + } + + @Override + public Metric nextMetricEvent() { + return null; + } + + @Override + public boolean hasDistributionSeriesEvent() { + return false; + } + + @Override + public DistributionSeries nextDistributionSeriesEvent() { + return null; + } + + @Override + public boolean hasLogMessageEvent() { + return false; + } + + @Override + public LogMessage nextLogMessageEvent() { + return null; + } + } +} diff --git a/telemetry/src/main/java/datadog/telemetry/HostInfo.java b/telemetry/src/main/java/datadog/telemetry/HostInfo.java index 9cab25f5da0..5f4986d6e18 100644 --- a/telemetry/src/main/java/datadog/telemetry/HostInfo.java +++ b/telemetry/src/main/java/datadog/telemetry/HostInfo.java @@ -17,7 +17,7 @@ public class HostInfo { private static final Path PROC_VERSION = FileSystems.getDefault().getPath("/proc/version"); - private static final Logger log = LoggerFactory.getLogger(RequestBuilder.class); + private static final Logger log = LoggerFactory.getLogger(TelemetryRequestBody.class); private static String hostname; private static String osName; diff --git a/telemetry/src/main/java/datadog/telemetry/HttpClient.java b/telemetry/src/main/java/datadog/telemetry/HttpClient.java deleted file mode 100644 index 367936d7c26..00000000000 --- a/telemetry/src/main/java/datadog/telemetry/HttpClient.java +++ /dev/null @@ -1,50 +0,0 @@ -package datadog.telemetry; - -import java.io.IOException; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class HttpClient { - public static final String DD_TELEMETRY_REQUEST_TYPE = "DD-Telemetry-Request-Type"; - - public enum Result { - SUCCESS, - FAILURE, - NOT_FOUND - } - - private static final Logger log = LoggerFactory.getLogger(HttpClient.class); - - private final OkHttpClient httpClient; - - public HttpClient(OkHttpClient httpClient) { - this.httpClient = httpClient; - } - - public Result sendRequest(Request request) { - String requestType = request.header(DD_TELEMETRY_REQUEST_TYPE); - try (Response response = httpClient.newCall(request).execute()) { - if (response.code() == 404) { - log.debug("Telemetry endpoint is disabled, dropping {} message", requestType); - return Result.NOT_FOUND; - } - if (!response.isSuccessful()) { - log.debug( - "Telemetry message {} failed with: {} {} ", - requestType, - response.code(), - response.message()); - return Result.FAILURE; - } - } catch (IOException e) { - log.debug("Telemetry message {} failed with exception: {}", requestType, e.toString()); - return Result.FAILURE; - } - - log.debug("Telemetry message {} sent successfully", requestType); - return Result.SUCCESS; - } -} diff --git a/telemetry/src/main/java/datadog/telemetry/RequestBuilder.java b/telemetry/src/main/java/datadog/telemetry/RequestBuilder.java deleted file mode 100644 index 494353c07a7..00000000000 --- a/telemetry/src/main/java/datadog/telemetry/RequestBuilder.java +++ /dev/null @@ -1,271 +0,0 @@ -package datadog.telemetry; - -import com.squareup.moshi.JsonWriter; -import datadog.communication.ddagent.TracerVersion; -import datadog.telemetry.api.ConfigChange; -import datadog.telemetry.api.DistributionSeries; -import datadog.telemetry.api.Integration; -import datadog.telemetry.api.LogMessage; -import datadog.telemetry.api.Metric; -import datadog.telemetry.api.RequestType; -import datadog.telemetry.dependency.Dependency; -import datadog.trace.api.Config; -import datadog.trace.api.DDTags; -import datadog.trace.api.Platform; -import java.io.IOException; -import java.util.List; -import java.util.concurrent.atomic.AtomicLong; -import javax.annotation.Nullable; -import okhttp3.HttpUrl; -import okhttp3.MediaType; -import okhttp3.Request; -import okhttp3.RequestBody; -import okio.Buffer; -import okio.BufferedSink; - -public class RequestBuilder extends RequestBody { - - public static class SerializationException extends RuntimeException { - public SerializationException(String requestPartName, Throwable cause) { - super("Failed serializing Telemetry request " + requestPartName + " part!", cause); - } - } - - private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8"); - private static final String API_VERSION = "v1"; - private static final AtomicLong SEQ_ID = new AtomicLong(); - private static final String TELEMETRY_NAMESPACE_TAG_TRACER = "tracers"; - - private final Buffer body = new Buffer(); - private final JsonWriter bodyWriter = JsonWriter.of(body); - private final RequestType requestType; - private final Request request; - private final boolean debug; - - private enum CommonData { - INSTANCE; - - Config config = Config.get(); - String env = config.getEnv(); - String langVersion = Platform.getLangVersion(); - String runtimeName = Platform.getRuntimeVendor(); - String runtimePatches = Platform.getRuntimePatches(); - String runtimeVersion = Platform.getRuntimeVersion(); - String serviceName = config.getServiceName(); - String serviceVersion = config.getVersion(); - String runtimeId = config.getRuntimeId(); - String architecture = HostInfo.getArchitecture(); - String hostname = HostInfo.getHostname(); - String kernelName = HostInfo.getKernelName(); - String kernelRelease = HostInfo.getKernelRelease(); - String kernelVersion = HostInfo.getKernelVersion(); - String osName = HostInfo.getOsName(); - String osVersion = HostInfo.getOsVersion(); - } - - RequestBuilder(RequestType requestType, HttpUrl httpUrl) { - this(requestType, httpUrl, false); - } - - RequestBuilder(RequestType requestType, HttpUrl httpUrl, boolean debug) { - this.requestType = requestType; - this.request = - new Request.Builder() - .url(httpUrl) - .addHeader("Content-Type", String.valueOf(JSON)) - .addHeader("DD-Telemetry-API-Version", API_VERSION) - .addHeader("DD-Telemetry-Request-Type", String.valueOf(requestType)) - .addHeader("DD-Client-Library-Language", "jvm") - .addHeader("DD-Client-Library-Version", TracerVersion.TRACER_VERSION) - .post(this) - .build(); - this.debug = debug; - } - - public void writeHeader() { - try { - CommonData commonData = CommonData.INSTANCE; - bodyWriter.beginObject(); - bodyWriter.name("api_version").value(API_VERSION); - - bodyWriter.name("application"); - bodyWriter.beginObject(); - bodyWriter.name("env").value(commonData.env); - bodyWriter.name("language_name").value(DDTags.LANGUAGE_TAG_VALUE); - bodyWriter.name("language_version").value(commonData.langVersion); - bodyWriter.name("runtime_name").value(commonData.runtimeName); - bodyWriter.name("runtime_patches").value(commonData.runtimePatches); // optional - bodyWriter.name("runtime_version").value(commonData.runtimeVersion); - bodyWriter.name("service_name").value(commonData.serviceName); - bodyWriter.name("service_version").value(commonData.serviceVersion); - bodyWriter.name("tracer_version").value(TracerVersion.TRACER_VERSION); - bodyWriter.endObject(); - - if (debug) { - bodyWriter.name("debug").value(true); - } - bodyWriter.name("host"); - bodyWriter.beginObject(); - bodyWriter.name("architecture").value(commonData.architecture); - bodyWriter.name("hostname").value(commonData.hostname); - // only applicable to UNIX based OS - bodyWriter.name("kernel_name").value(commonData.kernelName); - bodyWriter.name("kernel_release").value(commonData.kernelRelease); - bodyWriter.name("kernel_version").value(commonData.kernelVersion); - bodyWriter.name("os").value(commonData.osName); - bodyWriter.name("os_version").value(commonData.osVersion); // optional - bodyWriter.endObject(); - - bodyWriter.name("request_type").value(requestType.toString()); - bodyWriter.name("runtime_id").value(commonData.runtimeId); - bodyWriter.name("seq_id").value(SEQ_ID.incrementAndGet()); - bodyWriter.name("tracer_time").value(System.currentTimeMillis() / 1000L); - - bodyWriter.name("payload"); - bodyWriter.beginObject(); - } catch (Exception ex) { - throw new SerializationException("header", ex); - } - } - - public void writeMetrics(List series) { - try { - bodyWriter.name("namespace").value(TELEMETRY_NAMESPACE_TAG_TRACER); - bodyWriter.name("series"); - bodyWriter.beginArray(); - for (Metric m : series) { - bodyWriter.beginObject(); - bodyWriter.name("namespace").value(m.getNamespace()); - bodyWriter.name("common").value(m.getCommon()); - bodyWriter.name("metric").value(m.getMetric()); - bodyWriter.name("points").jsonValue(m.getPoints()); - bodyWriter.name("tags").jsonValue(m.getTags()); - if (m.getType() != null) bodyWriter.name("type").value(m.getType().toString()); - bodyWriter.endObject(); - } - bodyWriter.endArray(); - } catch (Exception ex) { - throw new SerializationException("metrics payload", ex); - } - } - - public void writeDistributionsEvent(List series) { - try { - bodyWriter.name("namespace").value(TELEMETRY_NAMESPACE_TAG_TRACER); - bodyWriter.name("series"); - bodyWriter.beginArray(); - for (DistributionSeries ds : series) { - bodyWriter.beginObject(); - bodyWriter.name("metric").value(ds.getMetric()); - bodyWriter.name("points").jsonValue(ds.getPoints()); - bodyWriter.name("tags").jsonValue(ds.getTags()); - bodyWriter.name("common").value(ds.getCommon()); - bodyWriter.name("namespace").value(ds.getNamespace()); - bodyWriter.endObject(); - } - bodyWriter.endArray(); - } catch (Exception ex) { - throw new SerializationException("distribution series payload", ex); - } - } - - public void writeLogsEvent(List messages) { - try { - bodyWriter.name("logs").beginArray(); - for (LogMessage m : messages) { - bodyWriter.beginObject(); - bodyWriter.name("message").value(m.getMessage()); - bodyWriter.name("level").value(String.valueOf(m.getLevel())); - bodyWriter.name("tags").value(m.getTags()); - bodyWriter.name("stack_trace").value(m.getStackTrace()); - bodyWriter.name("tracer_time").value(m.getTracerTime()); - bodyWriter.endObject(); - } - bodyWriter.endArray(); - } catch (Exception ex) { - throw new SerializationException("logs payload", ex); - } - } - - public void writeConfigChangeEvent(List configChanges) { - if (configChanges != null) { - try { - bodyWriter.name("configuration").beginArray(); - for (ConfigChange cc : configChanges) { - bodyWriter.beginObject(); - bodyWriter.name("name").value(cc.name); - bodyWriter.name("value").jsonValue(cc.value); - bodyWriter.endObject(); - } - bodyWriter.endArray(); - } catch (Exception ex) { - throw new SerializationException("config changes payload", ex); - } - } - } - - public void writeDependenciesLoadedEvent(List dependencies) { - try { - bodyWriter.name("dependencies"); - bodyWriter.beginArray(); - for (Dependency d : dependencies) { - bodyWriter.beginObject(); - bodyWriter.name("hash").value(d.hash); - bodyWriter.name("name").value(d.name); - bodyWriter.name("type").value("PlatformStandard"); - bodyWriter.name("version").value(d.version); - bodyWriter.endObject(); - } - bodyWriter.endArray(); - } catch (Exception ex) { - throw new SerializationException("dependencies payload", ex); - } - } - - public void writeIntegrationsEvent(List integrations) { - try { - bodyWriter.name("integrations"); - bodyWriter.beginArray(); - for (Integration i : integrations) { - bodyWriter.beginObject(); - bodyWriter.name("enabled").value(i.enabled); - bodyWriter.name("name").value(i.name); - bodyWriter.endObject(); - } - bodyWriter.endArray(); - } catch (Exception ex) { - throw new SerializationException("integrations payload", ex); - } - } - - public void writeFooter() { - try { - bodyWriter.endObject(); // payload - bodyWriter.endObject(); // request - } catch (Exception ex) { - throw new SerializationException("footer", ex); - } - } - - /** - * @return associated request.
- * NOTE: Its body can still be extended with write methods until it's been sent. That's - * because the request body is lazily evaluated and backed with a buffer that can still be - * extended until the request is sent.
- * TODO: see if there is a better way to express this peculiarity in the code. - */ - public Request request() { - return request; - } - - @Nullable - @Override - public MediaType contentType() { - return JSON; - } - - @Override - public void writeTo(BufferedSink sink) throws IOException { - sink.write(body, body.size()); - } -} diff --git a/telemetry/src/main/java/datadog/telemetry/TelemetryClient.java b/telemetry/src/main/java/datadog/telemetry/TelemetryClient.java new file mode 100644 index 00000000000..3e218a0f6b7 --- /dev/null +++ b/telemetry/src/main/java/datadog/telemetry/TelemetryClient.java @@ -0,0 +1,100 @@ +package datadog.telemetry; + +import datadog.communication.http.OkHttpUtils; +import java.io.IOException; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class TelemetryClient { + + public enum Result { + SUCCESS, + FAILURE, + NOT_FOUND; + } + + public static TelemetryClient buildAgentClient(OkHttpClient okHttpClient, HttpUrl agentUrl) { + HttpUrl agentTelemetryUrl = + agentUrl.newBuilder().addPathSegments(AGENT_TELEMETRY_API_ENDPOINT).build(); + return new TelemetryClient(okHttpClient, agentTelemetryUrl, null); + } + + public static TelemetryClient buildIntakeClient(String site, long timeoutMillis, String apiKey) { + if (apiKey == null) { + log.warn("Cannot create Telemetry Intake because API_KEY unspecified."); + return null; + } + + String prefix = ""; + if (site.endsWith("datad0g.com")) { + prefix = "all-http-intake.logs."; + } else if (site.endsWith("datadoghq.com")) { + prefix = "instrumentation-telemetry-intake."; + } + + String telemetryUrl = "https://" + prefix + site + "/api/v2/apmtelemetry"; + HttpUrl url; + try { + url = HttpUrl.get(telemetryUrl); + } catch (IllegalArgumentException e) { + log.error("Can't create Telemetry URL for {}", telemetryUrl); + return null; + } + + OkHttpClient httpClient = OkHttpUtils.buildHttpClient(url, timeoutMillis); + return new TelemetryClient(httpClient, url, apiKey); + } + + private static final Logger log = LoggerFactory.getLogger(TelemetryClient.class); + + private static final String AGENT_TELEMETRY_API_ENDPOINT = "telemetry/proxy/api/v2/apmtelemetry"; + private static final String DD_TELEMETRY_REQUEST_TYPE = "DD-Telemetry-Request-Type"; + + private final OkHttpClient okHttpClient; + private final HttpUrl url; + private final String apiKey; + + public TelemetryClient(OkHttpClient okHttpClient, HttpUrl url, String apiKey) { + this.okHttpClient = okHttpClient; + this.url = url; + this.apiKey = apiKey; + } + + public HttpUrl getUrl() { + return url; + } + + public Result sendHttpRequest(Request.Builder httpRequestBuilder) { + httpRequestBuilder.url(url); + if (apiKey != null) { + httpRequestBuilder.addHeader("DD-API-KEY", apiKey); + } + + Request httpRequest = httpRequestBuilder.build(); + String requestType = httpRequest.header(DD_TELEMETRY_REQUEST_TYPE); + try (Response response = okHttpClient.newCall(httpRequest).execute()) { + if (response.code() == 404) { + log.debug("Telemetry endpoint is disabled, dropping {} message.", requestType); + return Result.NOT_FOUND; + } + if (!response.isSuccessful()) { + log.debug( + "Telemetry message {} failed with: {} {}.", + requestType, + response.code(), + response.message()); + return Result.FAILURE; + } + } catch (IOException e) { + log.debug("Telemetry message {} failed with exception: {}.", requestType, e.toString()); + return Result.FAILURE; + } + + log.debug("Telemetry message {} sent successfully to {}.", requestType, url); + return Result.SUCCESS; + } +} diff --git a/telemetry/src/main/java/datadog/telemetry/TelemetryRequest.java b/telemetry/src/main/java/datadog/telemetry/TelemetryRequest.java new file mode 100644 index 00000000000..19f456a6fbd --- /dev/null +++ b/telemetry/src/main/java/datadog/telemetry/TelemetryRequest.java @@ -0,0 +1,191 @@ +package datadog.telemetry; + +import datadog.common.container.ContainerInfo; +import datadog.communication.ddagent.TracerVersion; +import datadog.telemetry.api.DistributionSeries; +import datadog.telemetry.api.Integration; +import datadog.telemetry.api.LogMessage; +import datadog.telemetry.api.Metric; +import datadog.telemetry.api.RequestType; +import datadog.telemetry.dependency.Dependency; +import datadog.trace.api.ConfigSetting; +import datadog.trace.api.DDTags; +import datadog.trace.api.InstrumenterConfig; +import datadog.trace.api.ProductActivation; +import java.io.IOException; +import okhttp3.MediaType; +import okhttp3.Request; + +public class TelemetryRequest { + static final String API_VERSION = "v2"; + static final MediaType JSON = MediaType.parse("application/json; charset=utf-8"); + + private final EventSource eventSource; + private final EventSink eventSink; + private final long messageBytesSoftLimit; + private final RequestType requestType; + private final boolean debug; + private final TelemetryRequestBody requestBody; + + public TelemetryRequest( + EventSource eventSource, + EventSink eventSink, + long messageBytesSoftLimit, + RequestType requestType, + boolean debug) { + this.eventSource = eventSource; + this.eventSink = eventSink; + this.messageBytesSoftLimit = messageBytesSoftLimit; + this.requestType = requestType; + this.debug = debug; + this.requestBody = new TelemetryRequestBody(requestType); + this.requestBody.beginRequest(debug); + } + + public Request.Builder httpRequest() { + long bodySize = requestBody.endRequest(); + + Request.Builder builder = + new Request.Builder() + .addHeader("Content-Type", String.valueOf(JSON)) + .addHeader("Content-Length", String.valueOf(bodySize)) + .addHeader("DD-Telemetry-API-Version", API_VERSION) + .addHeader("DD-Telemetry-Request-Type", String.valueOf(this.requestType)) + .addHeader("DD-Client-Library-Language", DDTags.LANGUAGE_TAG_VALUE) + .addHeader("DD-Client-Library-Version", TracerVersion.TRACER_VERSION) + .post(requestBody); + + final String containerId = ContainerInfo.get().getContainerId(); + if (containerId != null) { + builder.addHeader("Datadog-Container-ID", containerId); + } + + if (debug) { + builder.addHeader("DD-Telemetry-Debug-Enabled", "true"); + } + + return builder; + } + + public void writeConfigurations() { + if (!isWithinSizeLimits() || !eventSource.hasConfigChangeEvent()) { + return; + } + try { + requestBody.beginConfiguration(); + while (eventSource.hasConfigChangeEvent() && isWithinSizeLimits()) { + ConfigSetting event = eventSource.nextConfigChangeEvent(); + requestBody.writeConfiguration(event); + eventSink.addConfigChangeEvent(event); + } + requestBody.endConfiguration(); + } catch (IOException e) { + throw new TelemetryRequestBody.SerializationException("configuration-object", e); + } + } + + public void writeProducts() { + InstrumenterConfig instrumenterConfig = InstrumenterConfig.get(); + try { + boolean appsecEnabled = + instrumenterConfig.getAppSecActivation() != ProductActivation.FULLY_DISABLED; + boolean profilerEnabled = instrumenterConfig.isProfilingEnabled(); + requestBody.writeProducts(appsecEnabled, profilerEnabled); + } catch (IOException e) { + throw new TelemetryRequestBody.SerializationException("products", e); + } + } + + public void writeIntegrations() { + if (!isWithinSizeLimits() || !eventSource.hasIntegrationEvent()) { + return; + } + try { + requestBody.beginIntegrations(); + while (eventSource.hasIntegrationEvent() && isWithinSizeLimits()) { + Integration event = eventSource.nextIntegrationEvent(); + requestBody.writeIntegration(event); + eventSink.addIntegrationEvent(event); + } + requestBody.endIntegrations(); + } catch (IOException e) { + throw new TelemetryRequestBody.SerializationException("integrations-message", e); + } + } + + public void writeDependencies() { + if (!isWithinSizeLimits() || !eventSource.hasDependencyEvent()) { + return; + } + try { + requestBody.beginDependencies(); + while (eventSource.hasDependencyEvent() && isWithinSizeLimits()) { + Dependency event = eventSource.nextDependencyEvent(); + requestBody.writeDependency(event); + eventSink.addDependencyEvent(event); + } + requestBody.endDependencies(); + } catch (IOException e) { + throw new TelemetryRequestBody.SerializationException("dependencies-message", e); + } + } + + public void writeMetrics() { + if (!isWithinSizeLimits() || !eventSource.hasMetricEvent()) { + return; + } + try { + requestBody.beginMetrics(); + while (eventSource.hasMetricEvent() && isWithinSizeLimits()) { + Metric event = eventSource.nextMetricEvent(); + requestBody.writeMetric(event); + eventSink.addMetricEvent(event); + } + requestBody.endMetrics(); + } catch (IOException e) { + throw new TelemetryRequestBody.SerializationException("metrics-message", e); + } + } + + public void writeDistributions() { + if (!isWithinSizeLimits() || !eventSource.hasDistributionSeriesEvent()) { + return; + } + try { + requestBody.beginDistributions(); + while (eventSource.hasDistributionSeriesEvent() && isWithinSizeLimits()) { + DistributionSeries event = eventSource.nextDistributionSeriesEvent(); + requestBody.writeDistribution(event); + eventSink.addDistributionSeriesEvent(event); + } + requestBody.endDistributions(); + } catch (IOException e) { + throw new TelemetryRequestBody.SerializationException("distributions-message", e); + } + } + + public void writeLogs() { + if (!isWithinSizeLimits() || !eventSource.hasLogMessageEvent()) { + return; + } + try { + requestBody.beginLogs(); + while (eventSource.hasLogMessageEvent() && isWithinSizeLimits()) { + LogMessage event = eventSource.nextLogMessageEvent(); + requestBody.writeLog(event); + eventSink.addLogMessageEvent(event); + } + requestBody.endLogs(); + } catch (IOException e) { + throw new TelemetryRequestBody.SerializationException("logs-message", e); + } + } + + public void writeHeartbeat() { + requestBody.writeHeartbeatEvent(); + } + + private boolean isWithinSizeLimits() { + return requestBody.size() < messageBytesSoftLimit; + } +} diff --git a/telemetry/src/main/java/datadog/telemetry/TelemetryRequestBody.java b/telemetry/src/main/java/datadog/telemetry/TelemetryRequestBody.java new file mode 100644 index 00000000000..074e4386783 --- /dev/null +++ b/telemetry/src/main/java/datadog/telemetry/TelemetryRequestBody.java @@ -0,0 +1,327 @@ +package datadog.telemetry; + +import com.squareup.moshi.JsonWriter; +import datadog.communication.ddagent.TracerVersion; +import datadog.telemetry.api.DistributionSeries; +import datadog.telemetry.api.Integration; +import datadog.telemetry.api.LogMessage; +import datadog.telemetry.api.Metric; +import datadog.telemetry.api.RequestType; +import datadog.telemetry.dependency.Dependency; +import datadog.trace.api.Config; +import datadog.trace.api.ConfigSetting; +import datadog.trace.api.DDTags; +import datadog.trace.api.Platform; +import java.io.IOException; +import java.util.concurrent.atomic.AtomicLong; +import javax.annotation.Nullable; +import okhttp3.MediaType; +import okhttp3.RequestBody; +import okio.Buffer; +import okio.BufferedSink; + +public class TelemetryRequestBody extends RequestBody { + + public static class SerializationException extends RuntimeException { + public SerializationException(String requestPartName, Throwable cause) { + super("Failed serializing Telemetry " + requestPartName + " part!", cause); + } + } + + private static final AtomicLong SEQ_ID = new AtomicLong(); + private static final String TELEMETRY_NAMESPACE_TAG_TRACER = "tracers"; + + private final RequestType requestType; + private final Buffer body; + private final JsonWriter bodyWriter; + + /** Exists in a separate class to avoid startup toll */ + private static class CommonData { + final Config config = Config.get(); + final String env = config.getEnv(); + final String langVersion = Platform.getLangVersion(); + final String runtimeName = Platform.getRuntimeVendor(); + final String runtimePatches = Platform.getRuntimePatches(); + final String runtimeVersion = Platform.getRuntimeVersion(); + final String serviceName = config.getServiceName(); + final String serviceVersion = config.getVersion(); + final String runtimeId = config.getRuntimeId(); + final String architecture = HostInfo.getArchitecture(); + final String hostname = HostInfo.getHostname(); + final String kernelName = HostInfo.getKernelName(); + final String kernelRelease = HostInfo.getKernelRelease(); + final String kernelVersion = HostInfo.getKernelVersion(); + final String osName = HostInfo.getOsName(); + final String osVersion = HostInfo.getOsVersion(); + } + + private static final CommonData commonData = new CommonData(); + + TelemetryRequestBody(RequestType requestType) { + this.requestType = requestType; + this.body = new Buffer(); + this.bodyWriter = JsonWriter.of(body); + } + + public void beginRequest(boolean debug) { + try { + bodyWriter.beginObject(); + bodyWriter.name("api_version").value(TelemetryRequest.API_VERSION); + // naming_schema_version - optional + bodyWriter.name("runtime_id").value(commonData.runtimeId); + bodyWriter.name("seq_id").value(SEQ_ID.incrementAndGet()); + bodyWriter.name("tracer_time").value(System.currentTimeMillis() / 1000L); + + bodyWriter.name("application"); + bodyWriter.beginObject(); + bodyWriter.name("service_name").value(commonData.serviceName); + bodyWriter.name("env").value(commonData.env); + bodyWriter.name("service_version").value(commonData.serviceVersion); + bodyWriter.name("tracer_version").value(TracerVersion.TRACER_VERSION); + bodyWriter.name("language_name").value(DDTags.LANGUAGE_TAG_VALUE); + bodyWriter.name("language_version").value(commonData.langVersion); + bodyWriter.name("runtime_name").value(commonData.runtimeName); + bodyWriter.name("runtime_version").value(commonData.runtimeVersion); + bodyWriter.name("runtime_patches").value(commonData.runtimePatches); // optional + bodyWriter.endObject(); + + if (debug) { + bodyWriter.name("debug").value(true); + } + bodyWriter.name("host"); + bodyWriter.beginObject(); + bodyWriter.name("hostname").value(commonData.hostname); + bodyWriter.name("os").value(commonData.osName); + bodyWriter.name("os_version").value(commonData.osVersion); // optional + bodyWriter.name("architecture").value(commonData.architecture); + // only applicable to UNIX based OS + bodyWriter.name("kernel_name").value(commonData.kernelName); + bodyWriter.name("kernel_release").value(commonData.kernelRelease); + bodyWriter.name("kernel_version").value(commonData.kernelVersion); + bodyWriter.endObject(); + + bodyWriter.name("request_type").value(this.requestType.toString()); + + switch (this.requestType) { + case APP_STARTED: + case APP_EXTENDED_HEARTBEAT: + bodyWriter.name("payload"); + bodyWriter.beginObject(); + break; + case MESSAGE_BATCH: + bodyWriter.name("payload"); + bodyWriter.beginArray(); + break; + default: + } + } catch (Exception ex) { + throw new SerializationException("begin-request", ex); + } + } + + /** @return body size in bytes */ + public long endRequest() { + try { + switch (this.requestType) { + case APP_STARTED: + case APP_EXTENDED_HEARTBEAT: + bodyWriter.endObject(); // payload + break; + case MESSAGE_BATCH: + bodyWriter.endArray(); // payloads + break; + default: + } + bodyWriter.endObject(); // request + return body.size(); + } catch (Exception ex) { + throw new SerializationException("end-request", ex); + } + } + + public void writeHeartbeatEvent() { + beginMessageIfBatch(RequestType.APP_HEARTBEAT); + endMessageIfBatch(RequestType.APP_HEARTBEAT); + } + + public void beginMetrics() throws IOException { + beginMessageIfBatch(RequestType.GENERATE_METRICS); + bodyWriter.name("namespace").value(TELEMETRY_NAMESPACE_TAG_TRACER); + bodyWriter.name("series").beginArray(); + } + + public void writeMetric(Metric m) throws IOException { + bodyWriter.beginObject(); + bodyWriter.name("metric").value(m.getMetric()); + bodyWriter.name("points").jsonValue(m.getPoints()); + // interval - optional + if (m.getType() != null) bodyWriter.name("type").value(m.getType().toString()); + bodyWriter.name("tags").jsonValue(m.getTags()); // optional + bodyWriter.name("common").value(m.getCommon()); // optional + bodyWriter.name("namespace").value(m.getNamespace()); // optional + bodyWriter.endObject(); + } + + public void endMetrics() throws IOException { + bodyWriter.endArray(); + endMessageIfBatch(RequestType.GENERATE_METRICS); + } + + public void beginDistributions() throws IOException { + beginMessageIfBatch(RequestType.DISTRIBUTIONS); + bodyWriter.name("namespace").value(TELEMETRY_NAMESPACE_TAG_TRACER); + bodyWriter.name("series").beginArray(); + } + + public void writeDistribution(DistributionSeries ds) throws IOException { + bodyWriter.beginObject(); + bodyWriter.name("metric").value(ds.getMetric()); + bodyWriter.name("points").jsonValue(ds.getPoints()); + bodyWriter.name("tags").jsonValue(ds.getTags()); + bodyWriter.name("common").value(ds.getCommon()); + bodyWriter.name("namespace").value(ds.getNamespace()); + bodyWriter.endObject(); + } + + public void endDistributions() throws IOException { + bodyWriter.endArray(); + endMessageIfBatch(RequestType.DISTRIBUTIONS); + } + + public void beginLogs() throws IOException { + beginMessageIfBatch(RequestType.LOGS); + bodyWriter.name("logs").beginArray(); + } + + public void writeLog(LogMessage m) throws IOException { + bodyWriter.beginObject(); + bodyWriter.name("message").value(m.getMessage()); + bodyWriter.name("level").value(String.valueOf(m.getLevel())); + bodyWriter.name("tags").value(m.getTags()); // optional + bodyWriter.name("stack_trace").value(m.getStackTrace()); // optional + bodyWriter.name("tracer_time").value(m.getTracerTime()); // optional + bodyWriter.endObject(); + } + + public void endLogs() throws IOException { + bodyWriter.endArray(); + endMessageIfBatch(RequestType.LOGS); + } + + public void beginConfiguration() throws IOException { + beginMessageIfBatch(RequestType.APP_CLIENT_CONFIGURATION_CHANGE); + bodyWriter.name("configuration").beginArray(); + } + + public void writeConfiguration(ConfigSetting configSetting) throws IOException { + bodyWriter.beginObject(); + bodyWriter.name("name").value(configSetting.key); + bodyWriter.setSerializeNulls(true); + bodyWriter.name("value").jsonValue(configSetting.value); + bodyWriter.setSerializeNulls(false); + bodyWriter.name("origin").jsonValue(configSetting.origin.value); + bodyWriter.endObject(); + } + + public void endConfiguration() throws IOException { + bodyWriter.endArray(); + endMessageIfBatch(RequestType.APP_CLIENT_CONFIGURATION_CHANGE); + } + + public void beginIntegrations() throws IOException { + beginMessageIfBatch(RequestType.APP_INTEGRATIONS_CHANGE); + bodyWriter.name("integrations").beginArray(); + } + + public void writeIntegration(Integration i) throws IOException { + bodyWriter.beginObject(); + bodyWriter.name("enabled").value(i.enabled); + bodyWriter.name("name").value(i.name); + bodyWriter.endObject(); + } + + public void endIntegrations() throws IOException { + bodyWriter.endArray(); + endMessageIfBatch(RequestType.APP_INTEGRATIONS_CHANGE); + } + + public void beginDependencies() throws IOException { + beginMessageIfBatch(RequestType.APP_DEPENDENCIES_LOADED); + bodyWriter.name("dependencies").beginArray(); + } + + public void writeDependency(Dependency d) throws IOException { + bodyWriter.beginObject(); + bodyWriter.name("hash").value(d.hash); // optional + bodyWriter.name("name").value(d.name); + bodyWriter.name("version").value(d.version); // optional + bodyWriter.endObject(); + } + + public void endDependencies() throws IOException { + bodyWriter.endArray(); + endMessageIfBatch(RequestType.APP_DEPENDENCIES_LOADED); + } + + public void writeProducts(boolean appsecEnabled, boolean profilerEnabled) throws IOException { + bodyWriter.name("products"); + bodyWriter.beginObject(); + + bodyWriter.name("appsec"); + bodyWriter.beginObject(); + bodyWriter.name("enabled").value(appsecEnabled); + bodyWriter.endObject(); + + bodyWriter.name("profiler"); + bodyWriter.beginObject(); + bodyWriter.name("enabled").value(profilerEnabled); + bodyWriter.endObject(); + + bodyWriter.endObject(); + } + + @Nullable + @Override + public MediaType contentType() { + return TelemetryRequest.JSON; + } + + @Override + public void writeTo(BufferedSink sink) throws IOException { + sink.write(body, body.size()); + } + + public long size() { + return body.size(); + } + + private void beginMessageIfBatch(RequestType messageType) { + if (requestType != RequestType.MESSAGE_BATCH) { + return; + } + try { + bodyWriter.beginObject(); + bodyWriter.name("request_type").value(String.valueOf(messageType)); + if (messageType != RequestType.APP_HEARTBEAT) { + bodyWriter.name("payload"); + bodyWriter.beginObject(); + } + } catch (Exception ex) { + throw new SerializationException("begin-message", ex); + } + } + + private void endMessageIfBatch(RequestType messageType) { + if (requestType != RequestType.MESSAGE_BATCH) { + return; + } + try { + if (messageType != RequestType.APP_HEARTBEAT) { + bodyWriter.endObject(); // payload + } + bodyWriter.endObject(); // message + } catch (Exception ex) { + throw new SerializationException("end-message", ex); + } + } +} diff --git a/telemetry/src/main/java/datadog/telemetry/TelemetryRouter.java b/telemetry/src/main/java/datadog/telemetry/TelemetryRouter.java new file mode 100644 index 00000000000..076137c5852 --- /dev/null +++ b/telemetry/src/main/java/datadog/telemetry/TelemetryRouter.java @@ -0,0 +1,72 @@ +package datadog.telemetry; + +import datadog.communication.ddagent.DDAgentFeaturesDiscovery; +import javax.annotation.Nullable; +import okhttp3.HttpUrl; +import okhttp3.Request; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class TelemetryRouter { + private static final Logger log = LoggerFactory.getLogger(TelemetryRouter.class); + + private final DDAgentFeaturesDiscovery ddAgentFeaturesDiscovery; + private final TelemetryClient agentClient; + + private final TelemetryClient intakeClient; + + private TelemetryClient currentClient; + private boolean errorReported; + + public TelemetryRouter( + DDAgentFeaturesDiscovery ddAgentFeaturesDiscovery, + TelemetryClient agentClient, + @Nullable TelemetryClient intakeClient) { + this.ddAgentFeaturesDiscovery = ddAgentFeaturesDiscovery; + this.agentClient = agentClient; + this.intakeClient = intakeClient; + this.currentClient = agentClient; + } + + public TelemetryClient.Result sendRequest(TelemetryRequest request) { + ddAgentFeaturesDiscovery.discoverIfOutdated(); + boolean agentSupportsTelemetryProxy = ddAgentFeaturesDiscovery.supportsTelemetryProxy(); + + Request.Builder httpRequestBuilder = request.httpRequest(); + TelemetryClient.Result result = currentClient.sendHttpRequest(httpRequestBuilder); + + boolean requestFailed = result != TelemetryClient.Result.SUCCESS; + if (currentClient == agentClient) { + if (requestFailed) { + reportErrorOnce(currentClient.getUrl(), result); + if (intakeClient != null) { + log.info("Agent Telemetry endpoint failed. Telemetry will be sent to Intake."); + errorReported = false; + currentClient = intakeClient; + } + } + } else { + if (requestFailed) { + reportErrorOnce(currentClient.getUrl(), result); + } + if (agentSupportsTelemetryProxy || requestFailed) { + errorReported = false; + if (agentSupportsTelemetryProxy) { + log.info("Agent Telemetry endpoint is now available. Telemetry will be sent to Agent."); + } else { + log.info("Intake Telemetry endpoint failed. Telemetry will be sent to Agent."); + } + currentClient = agentClient; + } + } + + return result; + } + + private void reportErrorOnce(HttpUrl requestUrl, TelemetryClient.Result result) { + if (!errorReported) { + log.warn("Got {} sending telemetry request to {}.", result, requestUrl); + errorReported = true; + } + } +} diff --git a/telemetry/src/main/java/datadog/telemetry/TelemetryRunnable.java b/telemetry/src/main/java/datadog/telemetry/TelemetryRunnable.java index 0b8c6466169..14fa7df1aac 100644 --- a/telemetry/src/main/java/datadog/telemetry/TelemetryRunnable.java +++ b/telemetry/src/main/java/datadog/telemetry/TelemetryRunnable.java @@ -3,6 +3,7 @@ import datadog.telemetry.metric.MetricPeriodicAction; import datadog.trace.api.Config; import datadog.trace.api.ConfigCollector; +import datadog.trace.api.ConfigSetting; import datadog.trace.api.time.SystemTimeSource; import datadog.trace.api.time.TimeSource; import java.util.List; @@ -14,11 +15,14 @@ public class TelemetryRunnable implements Runnable { private static final Logger log = LoggerFactory.getLogger(TelemetryRunnable.class); + private static final int MAX_APP_STARTED_RETRIES = 3; + private static final int MAX_CONSECUTIVE_REQUESTS = 3; private final TelemetryService telemetryService; private final List actions; private final List actionsAtMetricsInterval; private final Scheduler scheduler; + private boolean startupEventSent; public TelemetryRunnable( final TelemetryService telemetryService, final List actions) { @@ -38,7 +42,8 @@ public TelemetryRunnable( timeSource, sleeper, (long) (Config.get().getTelemetryHeartbeatInterval() * 1000), - (long) (Config.get().getTelemetryMetricsInterval() * 1000)); + (long) (Config.get().getTelemetryMetricsInterval() * 1000), + Config.get().getTelemetryExtendedHeartbeatInterval() * 1000); } private List findMetricPeriodicActions( @@ -54,11 +59,21 @@ public void run() { // Ensure that Config has been initialized, so ConfigCollector can collect all settings first. Config.get(); + collectConfigChanges(); + scheduler.init(); while (!Thread.interrupted()) { try { - mainLoopIteration(); + if (!startupEventSent) { + startupEventSent = sendAppStartedEvent(); + } + if (startupEventSent) { + mainLoopIteration(); + } else { + // wait until next heartbeat interval before reattempting startup event + scheduler.shouldRunHeartbeat(); + } scheduler.sleepUntilNextIteration(); } catch (InterruptedException e) { log.debug("Interrupted; finishing telemetry thread"); @@ -67,15 +82,31 @@ public void run() { } flushPendingTelemetryData(); - telemetryService.sendAppClosingRequest(); + telemetryService.sendAppClosingEvent(); log.debug("Telemetry thread finished"); } - private void mainLoopIteration() throws InterruptedException { - Map collectedConfig = ConfigCollector.get().collect(); - if (!collectedConfig.isEmpty()) { - telemetryService.addConfiguration(collectedConfig); + /** + * Attempts to send an app-started event. + * + * @return `true` - if attempt was successful and `false` otherwise + */ + private boolean sendAppStartedEvent() { + int attempt = 0; + while (!Thread.interrupted() + && attempt < MAX_APP_STARTED_RETRIES + && !telemetryService.sendAppStartedEvent()) { + attempt += 1; + log.debug( + "Couldn't send an app-started event on {} attempt out of {}.", + attempt, + MAX_APP_STARTED_RETRIES); } + return Thread.interrupted() || attempt < MAX_APP_STARTED_RETRIES; + } + + private void mainLoopIteration() throws InterruptedException { + collectConfigChanges(); // Collect request metrics every N seconds (default 10s) if (scheduler.shouldRunMetrics()) { @@ -88,15 +119,32 @@ private void mainLoopIteration() throws InterruptedException { for (final TelemetryPeriodicAction action : this.actions) { action.doIteration(this.telemetryService); } - telemetryService.sendIntervalRequests(); + for (int i = 0; i < MAX_CONSECUTIVE_REQUESTS; i++) { + if (!telemetryService.sendTelemetryEvents()) { + // stop if there is no more data to be sent, or it failed to send a request + break; + } + } + } + + if (scheduler.shouldRunExtendedHeartbeat()) { + if (telemetryService.sendExtendedHeartbeat()) { + // advance next extended-heartbeat only if request was successful, otherwise reattempt it + // next heartbeat interval + scheduler.scheduleNextExtendedHeartbeat(); + } } } - private void flushPendingTelemetryData() { - Map collectedConfig = ConfigCollector.get().collect(); + private void collectConfigChanges() { + Map collectedConfig = ConfigCollector.get().collect(); if (!collectedConfig.isEmpty()) { telemetryService.addConfiguration(collectedConfig); } + } + + private void flushPendingTelemetryData() { + collectConfigChanges(); for (MetricPeriodicAction action : actionsAtMetricsInterval) { action.collector().prepareMetrics(); @@ -105,7 +153,7 @@ private void flushPendingTelemetryData() { for (final TelemetryPeriodicAction action : actions) { action.doIteration(telemetryService); } - telemetryService.sendIntervalRequests(); + telemetryService.sendTelemetryEvents(); } interface ThreadSleeper { @@ -131,8 +179,10 @@ static class Scheduler { private final TimeSource timeSource; private final ThreadSleeper sleeper; private final long heartbeatIntervalMs; + private final long extendedHeartbeatIntervalMs; private final long metricsIntervalMs; private long nextHeartbeatIntervalMs; + private long nextExtendedHeartbeatIntervalMs; private long nextMetricsIntervalMs; private long currentTime; @@ -140,13 +190,16 @@ public Scheduler( final TimeSource timeSource, final ThreadSleeper sleeper, final long heartbeatIntervalMs, - final long metricsIntervalMs) { + final long metricsIntervalMs, + final long extendedHeartbeatIntervalMs) { this.timeSource = timeSource; this.sleeper = sleeper; this.heartbeatIntervalMs = heartbeatIntervalMs; this.metricsIntervalMs = metricsIntervalMs; + this.extendedHeartbeatIntervalMs = extendedHeartbeatIntervalMs; nextHeartbeatIntervalMs = 0; nextMetricsIntervalMs = 0; + nextExtendedHeartbeatIntervalMs = 0; currentTime = 0; } @@ -155,6 +208,7 @@ public void init() { this.currentTime = currentTime; nextMetricsIntervalMs = currentTime; nextHeartbeatIntervalMs = currentTime; + scheduleNextExtendedHeartbeat(); } public boolean shouldRunMetrics() { @@ -173,6 +227,15 @@ public boolean shouldRunHeartbeat() { return false; } + public boolean shouldRunExtendedHeartbeat() { + return currentTime >= nextExtendedHeartbeatIntervalMs; + } + + public void scheduleNextExtendedHeartbeat() { + // schedule very first extended-heartbeat only after interval elapses + nextExtendedHeartbeatIntervalMs = currentTime + extendedHeartbeatIntervalMs; + } + public void sleepUntilNextIteration() { currentTime = timeSource.getCurrentTimeMillis(); @@ -190,8 +253,7 @@ public void sleepUntilNextIteration() { while (currentTime >= nextMetricsIntervalMs) { // If metric collection exceeded the interval, something went really wrong. Either there's a // very short interval (not default), or metric collection is taking abnormally long. We - // skip - // intervals in this case. + // skip intervals in this case. nextMetricsIntervalMs += metricsIntervalMs; } diff --git a/telemetry/src/main/java/datadog/telemetry/TelemetryService.java b/telemetry/src/main/java/datadog/telemetry/TelemetryService.java index 3565847a52f..2e2bffadb44 100644 --- a/telemetry/src/main/java/datadog/telemetry/TelemetryService.java +++ b/telemetry/src/main/java/datadog/telemetry/TelemetryService.java @@ -1,38 +1,27 @@ package datadog.telemetry; -import datadog.telemetry.api.ConfigChange; +import datadog.communication.ddagent.DDAgentFeaturesDiscovery; import datadog.telemetry.api.DistributionSeries; import datadog.telemetry.api.Integration; import datadog.telemetry.api.LogMessage; import datadog.telemetry.api.Metric; import datadog.telemetry.api.RequestType; import datadog.telemetry.dependency.Dependency; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; +import datadog.trace.api.ConfigSetting; import java.util.Map; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; -import javax.annotation.Nullable; -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; -import okhttp3.Request; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class TelemetryService { - private static final Logger log = LoggerFactory.getLogger(TelemetryService.class); - private static final String API_ENDPOINT = "telemetry/proxy/api/v2/apmtelemetry"; - private static final int MAX_ELEMENTS_PER_REQUEST = 100; + private static final Logger log = LoggerFactory.getLogger(TelemetryService.class); - // https://github.com/DataDog/instrumentation-telemetry-api-docs/blob/main/GeneratedDocumentation/ApiDocs/v2/producing-telemetry.md#when-to-use-it-1 - private static final int MAX_DEPENDENCIES_PER_REQUEST = 2000; + private static final long DEFAULT_MESSAGE_BYTES_SOFT_LIMIT = Math.round(5 * 1024 * 1024 * 0.75); - private final HttpClient httpClient; - private final int maxElementsPerReq; - private final int maxDepsPerReq; - private final BlockingQueue configurations = new LinkedBlockingQueue<>(); + private final TelemetryRouter telemetryRouter; + private final BlockingQueue configurations = new LinkedBlockingQueue<>(); private final BlockingQueue integrations = new LinkedBlockingQueue<>(); private final BlockingQueue dependencies = new LinkedBlockingQueue<>(); private final BlockingQueue metrics = @@ -43,9 +32,13 @@ public class TelemetryService { private final BlockingQueue distributionSeries = new LinkedBlockingQueue<>(1024); - private final HttpUrl httpUrl; + private final ExtendedHeartbeatData extendedHeartbeatData = new ExtendedHeartbeatData(); + private final EventSource.Queued eventSource = + new EventSource.Queued( + configurations, integrations, dependencies, metrics, distributionSeries, logMessages); - private boolean sentAppStarted; + private final long messageBytesSoftLimit; + private final boolean debug; /* * Keep track of Open Tracing and Open Telemetry integrations activation as they are mutually exclusive. @@ -53,32 +46,32 @@ public class TelemetryService { private boolean openTracingIntegrationEnabled; private boolean openTelemetryIntegrationEnabled; - public TelemetryService(final OkHttpClient okHttpClient, final HttpUrl httpUrl) { - this(new HttpClient(okHttpClient), httpUrl); - } - - public TelemetryService(final HttpClient httpClient, final HttpUrl httpUrl) { - this(httpClient, MAX_ELEMENTS_PER_REQUEST, MAX_DEPENDENCIES_PER_REQUEST, httpUrl); + public static TelemetryService build( + DDAgentFeaturesDiscovery ddAgentFeaturesDiscovery, + TelemetryClient agentClient, + TelemetryClient intakeClient, + boolean debug) { + TelemetryRouter telemetryRouter = + new TelemetryRouter(ddAgentFeaturesDiscovery, agentClient, intakeClient); + return new TelemetryService(telemetryRouter, DEFAULT_MESSAGE_BYTES_SOFT_LIMIT, debug); } // For testing purposes TelemetryService( - final HttpClient httpClient, - final int maxElementsPerReq, - final int maxDepsPerReq, - final HttpUrl agentUrl) { - this.httpClient = httpClient; - this.sentAppStarted = false; + final TelemetryRouter telemetryRouter, + final long messageBytesSoftLimit, + final boolean debug) { + this.telemetryRouter = telemetryRouter; this.openTracingIntegrationEnabled = false; this.openTelemetryIntegrationEnabled = false; - this.maxElementsPerReq = maxElementsPerReq; - this.maxDepsPerReq = maxDepsPerReq; - this.httpUrl = agentUrl.newBuilder().addPathSegments(API_ENDPOINT).build(); + this.messageBytesSoftLimit = messageBytesSoftLimit; + this.debug = debug; } - public boolean addConfiguration(Map configuration) { - for (Map.Entry entry : configuration.entrySet()) { - if (!this.configurations.offer(new ConfigChange(entry.getKey(), entry.getValue()))) { + public boolean addConfiguration(Map configuration) { + for (ConfigSetting cs : configuration.values()) { + extendedHeartbeatData.pushConfigSetting(cs); + if (!this.configurations.offer(cs)) { return false; } } @@ -86,17 +79,21 @@ public boolean addConfiguration(Map configuration) { } public boolean addDependency(Dependency dependency) { + extendedHeartbeatData.pushDependency(dependency); return this.dependencies.offer(dependency); } public boolean addIntegration(Integration integration) { - if ("opentelemetry-1".equals(integration.name) && integration.enabled) { - this.openTelemetryIntegrationEnabled = true; - warnAboutExclusiveIntegrations(); - } else if ("opentracing".equals(integration.name) && integration.enabled) { - this.openTracingIntegrationEnabled = true; + if ("opentelemetry-1".equals(integration.name)) { + openTelemetryIntegrationEnabled = integration.enabled; + } + if ("opentracing".equals(integration.name)) { + openTracingIntegrationEnabled = integration.enabled; + } + if (openTelemetryIntegrationEnabled && openTracingIntegrationEnabled) { warnAboutExclusiveIntegrations(); } + extendedHeartbeatData.pushIntegration(integration); return this.integrations.offer(integration); } @@ -114,260 +111,131 @@ public boolean addDistributionSeries(DistributionSeries series) { return this.distributionSeries.offer(series); } - public void sendAppClosingRequest() { - RequestBuilder rb = new RequestBuilder(RequestType.APP_CLOSING, httpUrl); - rb.writeHeader(); - rb.writeFooter(); - Request request = rb.request(); - httpClient.sendRequest(request); - } - - public void sendIntervalRequests() { - final State state = - new State( - configurations, integrations, dependencies, metrics, distributionSeries, logMessages); - if (!sentAppStarted) { - RequestBuilder rb = new RequestBuilder(RequestType.APP_STARTED, httpUrl); - rb.writeHeader(); - rb.writeConfigChangeEvent(state.configurations.getOrNull()); - rb.writeIntegrationsEvent(state.integrations.get(maxElementsPerReq)); - rb.writeDependenciesLoadedEvent(state.dependencies.get(maxElementsPerReq)); - rb.writeFooter(); - HttpClient.Result result = httpClient.sendRequest(rb.request()); - - if (result != HttpClient.Result.SUCCESS) { - // Do not send other telemetry messages unless app-started has been sent successfully. - state.rollback(); - return; - } - sentAppStarted = true; - state.commit(); - state.rollback(); - // When app-started is sent, we do not send more messages until the next interval. - return; - } - - { - RequestBuilder rb = new RequestBuilder(RequestType.APP_HEARTBEAT, httpUrl); - rb.writeHeader(); - rb.writeFooter(); - if (httpClient.sendRequest(rb.request()) == HttpClient.Result.NOT_FOUND) { - state.rollback(); - return; - } - } - - while (!state.configurations.isEmpty()) { - RequestBuilder rb = new RequestBuilder(RequestType.APP_CLIENT_CONFIGURATION_CHANGE, httpUrl); - rb.writeHeader(); - rb.writeConfigChangeEvent(state.configurations.get(maxElementsPerReq)); - rb.writeFooter(); - HttpClient.Result result = httpClient.sendRequest(rb.request()); - if (result == HttpClient.Result.SUCCESS) { - state.commit(); - } else if (result == HttpClient.Result.NOT_FOUND) { - state.rollback(); - return; - } else { - state.configurations.rollback(); - break; - } - } - - while (!state.integrations.isEmpty()) { - RequestBuilder rb = new RequestBuilder(RequestType.APP_INTEGRATIONS_CHANGE, httpUrl); - rb.writeHeader(); - rb.writeIntegrationsEvent(state.integrations.get(maxElementsPerReq)); - rb.writeFooter(); - HttpClient.Result result = httpClient.sendRequest(rb.request()); - if (result == HttpClient.Result.SUCCESS) { - state.commit(); - } else if (result == HttpClient.Result.NOT_FOUND) { - state.rollback(); - return; - } else { - state.integrations.rollback(); - break; - } - } - - while (!state.dependencies.isEmpty()) { - RequestBuilder rb = new RequestBuilder(RequestType.APP_DEPENDENCIES_LOADED, httpUrl); - rb.writeHeader(); - rb.writeDependenciesLoadedEvent(state.dependencies.get(maxDepsPerReq)); - rb.writeFooter(); - HttpClient.Result result = httpClient.sendRequest(rb.request()); - if (result == HttpClient.Result.SUCCESS) { - state.commit(); - } else if (result == HttpClient.Result.NOT_FOUND) { - state.rollback(); - return; - } else { - state.dependencies.rollback(); - break; - } - } - - while (!state.metrics.isEmpty()) { - RequestBuilder rb = new RequestBuilder(RequestType.GENERATE_METRICS, httpUrl); - rb.writeHeader(); - rb.writeMetrics(state.metrics.get(maxElementsPerReq)); - rb.writeFooter(); - HttpClient.Result result = httpClient.sendRequest(rb.request()); - if (result == HttpClient.Result.SUCCESS) { - state.commit(); - } else if (result == HttpClient.Result.NOT_FOUND) { - state.rollback(); - return; - } else { - state.metrics.rollback(); - break; - } - } - - while (!state.distributionSeries.isEmpty()) { - RequestBuilder rb = new RequestBuilder(RequestType.DISTRIBUTIONS, httpUrl); - rb.writeHeader(); - rb.writeDistributionsEvent(state.distributionSeries.get(maxElementsPerReq)); - rb.writeFooter(); - HttpClient.Result result = httpClient.sendRequest(rb.request()); - if (result == HttpClient.Result.SUCCESS) { - state.commit(); - } else if (result == HttpClient.Result.NOT_FOUND) { - state.rollback(); - return; - } else { - state.distributionSeries.rollback(); - break; - } - } - - while (!state.logMessages.isEmpty()) { - - RequestBuilder rb = new RequestBuilder(RequestType.LOGS, httpUrl); - rb.writeHeader(); - rb.writeLogsEvent(state.logMessages.get(maxElementsPerReq)); - rb.writeFooter(); - HttpClient.Result result = httpClient.sendRequest(rb.request()); - - if (result == HttpClient.Result.SUCCESS) { - state.commit(); - } else if (result == HttpClient.Result.NOT_FOUND) { - state.rollback(); - return; - } else { - state.logMessages.rollback(); - break; - } + public void sendAppClosingEvent() { + TelemetryRequest telemetryRequest = + new TelemetryRequest( + this.eventSource, + EventSink.NOOP, + messageBytesSoftLimit, + RequestType.APP_CLOSING, + debug); + if (telemetryRouter.sendRequest(telemetryRequest) != TelemetryClient.Result.SUCCESS) { + log.warn("Couldn't send app-closing event!"); } } - private void warnAboutExclusiveIntegrations() { - if (this.openTelemetryIntegrationEnabled && this.openTracingIntegrationEnabled) { - log.warn( - "Both Open Tracing and Open Telemetry integrations are enabled but mutually exclusive. Tracing performance can be degraded."); - } + // keeps track of unsent events from the previous attempt + private BufferedEvents bufferedEvents; + + /** @return true - if an app-started event has been successfully sent, false - otherwise */ + public boolean sendAppStartedEvent() { + EventSource eventSource; + EventSink eventSink; + if (bufferedEvents == null) { + eventSource = this.eventSource; + } else { + log.debug( + "Sending buffered telemetry events that couldn't have been sent on previous attempt"); + eventSource = bufferedEvents; + } + // use a buffer as a sink, so we can retry on the next attempt in case of a request failure + bufferedEvents = new BufferedEvents(); + eventSink = bufferedEvents; + + log.debug("Preparing app-started request"); + TelemetryRequest request = + new TelemetryRequest( + eventSource, eventSink, messageBytesSoftLimit, RequestType.APP_STARTED, debug); + request.writeProducts(); + request.writeConfigurations(); + if (telemetryRouter.sendRequest(request) == TelemetryClient.Result.SUCCESS) { + // discard already sent buffered event on the successful attempt + bufferedEvents = null; + return true; + } + return false; } - private static class StateList { - private final BlockingQueue queue; - private List batch; - private int consumed; - - public StateList(final BlockingQueue queue) { - this.queue = queue; - final int size = queue.size(); - this.batch = new ArrayList<>(size); - queue.drainTo(this.batch); - this.consumed = 0; - } - - public boolean isEmpty() { - return consumed >= batch.size(); - } - - @Nullable - public List getOrNull() { - final List result = get(); - if (result.isEmpty()) { - return null; - } - return result; - } - - public List get() { - return get(batch.size()); - } - - public List get(final int maxSize) { - if (consumed >= batch.size()) { - return Collections.emptyList(); - } - final int toIndex = Math.min(batch.size(), consumed + maxSize); - final List result = batch.subList(consumed, toIndex); - consumed += result.size(); - return result; - } - - public void commit() { - if (consumed >= batch.size()) { - batch = Collections.emptyList(); - } else { - batch = batch.subList(consumed, batch.size()); - } - consumed = 0; - } - - public void rollback() { - for (final T element : batch) { - // Ignore result, if the queue is full, we'll just lose data. - // TODO: Emit a metric when data is lost. - queue.offer(element); + /** + * @return true - only part of data has been sent because of the request size limit false - all + * data has been sent, or it has failed sending a request + */ + public boolean sendTelemetryEvents() { + EventSource eventSource; + EventSink eventSink; + if (bufferedEvents == null) { + log.debug("Sending telemetry events"); + eventSource = this.eventSource; + // use a buffer as a sink, so we can retry on the next attempt in case of a request failure + bufferedEvents = new BufferedEvents(); + eventSink = bufferedEvents; + } else { + log.debug( + "Sending buffered telemetry events that couldn't have been sent on previous attempt"); + eventSource = bufferedEvents; + eventSink = EventSink.NOOP; // TODO collect metrics for unsent events + } + TelemetryRequest request; + boolean isMoreDataAvailable = false; + if (eventSource.isEmpty()) { + log.debug("Preparing app-heartbeat request"); + request = + new TelemetryRequest( + eventSource, eventSink, messageBytesSoftLimit, RequestType.APP_HEARTBEAT, debug); + } else { + log.debug("Preparing message-batch request"); + request = + new TelemetryRequest( + eventSource, eventSink, messageBytesSoftLimit, RequestType.MESSAGE_BATCH, debug); + request.writeHeartbeat(); + request.writeConfigurations(); + request.writeIntegrations(); + request.writeDependencies(); + request.writeMetrics(); + request.writeDistributions(); + request.writeLogs(); + isMoreDataAvailable = !this.eventSource.isEmpty(); + } + + TelemetryClient.Result result = telemetryRouter.sendRequest(request); + if (result == TelemetryClient.Result.SUCCESS) { + log.debug("Telemetry request has been sent successfully."); + bufferedEvents = null; + return isMoreDataAvailable; + } else { + log.debug("Telemetry request has failed: {}", result); + if (eventSource == bufferedEvents) { + // TODO report metrics for discarded events + bufferedEvents = null; } - batch = Collections.emptyList(); - consumed = 0; } + return false; } - private static class State { - private final StateList configurations; - private final StateList integrations; - private final StateList dependencies; - private final StateList metrics; - private final StateList distributionSeries; - private final StateList logMessages; - - public State( - BlockingQueue configurations, - BlockingQueue integrations, - BlockingQueue dependencies, - BlockingQueue metrics, - BlockingQueue distributionSeries, - BlockingQueue logMessages) { - this.configurations = new StateList<>(configurations); - this.integrations = new StateList<>(integrations); - this.dependencies = new StateList<>(dependencies); - this.metrics = new StateList<>(metrics); - this.distributionSeries = new StateList<>(distributionSeries); - this.logMessages = new StateList<>(logMessages); - } - - public void rollback() { - this.configurations.rollback(); - this.integrations.rollback(); - this.dependencies.rollback(); - this.metrics.rollback(); - this.distributionSeries.rollback(); - this.logMessages.rollback(); - } + /** @return true - if extended heartbeat request sent successfully, otherwise false */ + public boolean sendExtendedHeartbeat() { + log.debug("Preparing message-batch request"); + EventSource extendedHeartbeatDataSnapshot = extendedHeartbeatData.snapshot(); + TelemetryRequest request = + new TelemetryRequest( + extendedHeartbeatDataSnapshot, + EventSink.NOOP, + messageBytesSoftLimit, + RequestType.APP_EXTENDED_HEARTBEAT, + debug); + request.writeConfigurations(); + request.writeDependencies(); + request.writeIntegrations(); + + TelemetryClient.Result result = telemetryRouter.sendRequest(request); + if (!extendedHeartbeatDataSnapshot.isEmpty()) { + log.warn("Telemetry Extended Heartbeat data does NOT fit in one request."); + } + return result == TelemetryClient.Result.SUCCESS; + } - public void commit() { - this.configurations.commit(); - this.integrations.commit(); - this.dependencies.commit(); - this.metrics.commit(); - this.distributionSeries.commit(); - this.logMessages.commit(); - } + void warnAboutExclusiveIntegrations() { + log.warn( + "Both OpenTracing and OpenTelemetry integrations are enabled but mutually exclusive. Tracing performance can be degraded."); } } diff --git a/telemetry/src/main/java/datadog/telemetry/TelemetrySystem.java b/telemetry/src/main/java/datadog/telemetry/TelemetrySystem.java index ce089e51e75..e249239f5bc 100644 --- a/telemetry/src/main/java/datadog/telemetry/TelemetrySystem.java +++ b/telemetry/src/main/java/datadog/telemetry/TelemetrySystem.java @@ -1,5 +1,6 @@ package datadog.telemetry; +import datadog.communication.ddagent.DDAgentFeaturesDiscovery; import datadog.communication.ddagent.SharedCommunicationObjects; import datadog.telemetry.TelemetryRunnable.TelemetryPeriodicAction; import datadog.telemetry.dependency.DependencyPeriodicAction; @@ -14,6 +15,7 @@ import java.lang.instrument.Instrumentation; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.TimeUnit; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -36,15 +38,19 @@ static DependencyService createDependencyService(Instrumentation instrumentation } static Thread createTelemetryRunnable( - TelemetryService telemetryService, DependencyService dependencyService) { + TelemetryService telemetryService, + DependencyService dependencyService, + boolean telemetryMetricsEnabled) { DEPENDENCY_SERVICE = dependencyService; List actions = new ArrayList<>(); - actions.add(new CoreMetricsPeriodicAction()); - actions.add(new IntegrationPeriodicAction()); - actions.add(new WafMetricPeriodicAction()); - if (Verbosity.OFF != Config.get().getIastTelemetryVerbosity()) { - actions.add(new IastMetricPeriodicAction()); + if (telemetryMetricsEnabled) { + actions.add(new CoreMetricsPeriodicAction()); + actions.add(new IntegrationPeriodicAction()); + actions.add(new WafMetricPeriodicAction()); + if (Verbosity.OFF != Config.get().getIastTelemetryVerbosity()) { + actions.add(new IastMetricPeriodicAction()); + } } if (null != dependencyService) { actions.add(new DependencyPeriodicAction(dependencyService)); @@ -58,10 +64,24 @@ static Thread createTelemetryRunnable( /** Called by reflection (see Agent.startTelemetry) */ public static void startTelemetry( Instrumentation instrumentation, SharedCommunicationObjects sco) { - sco.createRemaining(Config.get()); + Config config = Config.get(); + sco.createRemaining(config); DependencyService dependencyService = createDependencyService(instrumentation); - TelemetryService telemetryService = new TelemetryService(sco.okHttpClient, sco.agentUrl); - TELEMETRY_THREAD = createTelemetryRunnable(telemetryService, dependencyService); + boolean debug = config.isTelemetryDebugRequestsEnabled(); + DDAgentFeaturesDiscovery ddAgentFeaturesDiscovery = sco.featuresDiscovery(config); + + TelemetryClient agentClient = TelemetryClient.buildAgentClient(sco.okHttpClient, sco.agentUrl); + TelemetryClient intakeClient = + TelemetryClient.buildIntakeClient( + config.getSite(), + TimeUnit.SECONDS.toMillis(config.getAgentTimeout()), + config.getApiKey()); + TelemetryService telemetryService = + TelemetryService.build(ddAgentFeaturesDiscovery, agentClient, intakeClient, debug); + + boolean telemetryMetricsEnabled = config.isTelemetryMetricsEnabled(); + TELEMETRY_THREAD = + createTelemetryRunnable(telemetryService, dependencyService, telemetryMetricsEnabled); TELEMETRY_THREAD.start(); } diff --git a/telemetry/src/main/java/datadog/telemetry/api/ConfigChange.java b/telemetry/src/main/java/datadog/telemetry/api/ConfigChange.java deleted file mode 100644 index 542ee0496bf..00000000000 --- a/telemetry/src/main/java/datadog/telemetry/api/ConfigChange.java +++ /dev/null @@ -1,11 +0,0 @@ -package datadog.telemetry.api; - -public final class ConfigChange { - public final String name; - public final Object value; - - public ConfigChange(String name, Object value) { - this.name = name; - this.value = value; - } -} diff --git a/telemetry/src/main/java/datadog/telemetry/api/RequestType.java b/telemetry/src/main/java/datadog/telemetry/api/RequestType.java index e30ee0f9ee3..43d7018849a 100644 --- a/telemetry/src/main/java/datadog/telemetry/api/RequestType.java +++ b/telemetry/src/main/java/datadog/telemetry/api/RequestType.java @@ -2,14 +2,16 @@ public enum RequestType { APP_STARTED("app-started"), - APP_CLIENT_CONFIGURATION_CHANGE("app-client-configuration-change"), APP_DEPENDENCIES_LOADED("app-dependencies-loaded"), APP_INTEGRATIONS_CHANGE("app-integrations-change"), + APP_CLIENT_CONFIGURATION_CHANGE("app-client-configuration-change"), APP_HEARTBEAT("app-heartbeat"), + APP_EXTENDED_HEARTBEAT("app-extended-heartbeat"), APP_CLOSING("app-closing"), GENERATE_METRICS("generate-metrics"), LOGS("logs"), - DISTRIBUTIONS("distributions"); + DISTRIBUTIONS("distributions"), + MESSAGE_BATCH("message-batch"); private final String value; diff --git a/telemetry/src/main/java/datadog/telemetry/dependency/Dependency.java b/telemetry/src/main/java/datadog/telemetry/dependency/Dependency.java index abac935e796..9f9cedf55e7 100644 --- a/telemetry/src/main/java/datadog/telemetry/dependency/Dependency.java +++ b/telemetry/src/main/java/datadog/telemetry/dependency/Dependency.java @@ -57,6 +57,24 @@ public Dependency(String name, String version, String source, @Nullable String h this.hash = hash; } + @Override + public String toString() { + return "Dependency{" + + "name='" + + name + + '\'' + + ", version='" + + version + + '\'' + + ", source='" + + source + + '\'' + + ", hash='" + + hash + + '\'' + + '}'; + } + public static List fromMavenPom(JarFile jar) { if (jar == null) { return Collections.emptyList(); diff --git a/telemetry/src/test/groovy/datadog/telemetry/BufferedEventsSpecification.groovy b/telemetry/src/test/groovy/datadog/telemetry/BufferedEventsSpecification.groovy new file mode 100644 index 00000000000..1984d680635 --- /dev/null +++ b/telemetry/src/test/groovy/datadog/telemetry/BufferedEventsSpecification.groovy @@ -0,0 +1,108 @@ +package datadog.telemetry + +import datadog.telemetry.api.DistributionSeries +import datadog.telemetry.api.Integration +import datadog.telemetry.api.LogMessage +import datadog.telemetry.api.Metric +import datadog.telemetry.dependency.Dependency +import datadog.trace.api.ConfigOrigin +import datadog.trace.api.ConfigSetting +import datadog.trace.test.util.DDSpecification + +class BufferedEventsSpecification extends DDSpecification { + + def 'empty events'() { + def events = new BufferedEvents() + + expect: + events.isEmpty() + !events.hasConfigChangeEvent() + !events.hasDependencyEvent() + !events.hasDistributionSeriesEvent() + !events.hasIntegrationEvent() + !events.hasLogMessageEvent() + !events.hasMetricEvent() + } + + def 'return added events'() { + def events = new BufferedEvents() + def configSetting = new ConfigSetting("key", "value", ConfigOrigin.DEFAULT) + def dependency = new Dependency("name", "version", "source", "hash") + def series = new DistributionSeries() + def integration = new Integration("integration-name", true) + def logMessage = new LogMessage() + def metric = new Metric() + + when: + events.addConfigChangeEvent(configSetting) + + then: + !events.isEmpty() + events.hasConfigChangeEvent() + events.nextConfigChangeEvent() == configSetting + !events.hasConfigChangeEvent() + events.isEmpty() + + when: + events.addDependencyEvent(dependency) + + then: + !events.isEmpty() + events.hasDependencyEvent() + events.nextDependencyEvent() == dependency + !events.hasDependencyEvent() + events.isEmpty() + + when: + events.addDistributionSeriesEvent(series) + + then: + !events.isEmpty() + events.hasDistributionSeriesEvent() + events.nextDistributionSeriesEvent() == series + !events.hasDistributionSeriesEvent() + events.isEmpty() + + when: + events.addIntegrationEvent(integration) + + then: + !events.isEmpty() + events.hasIntegrationEvent() + events.nextIntegrationEvent() == integration + !events.hasIntegrationEvent() + events.isEmpty() + + when: + events.addLogMessageEvent(logMessage) + + then: + !events.isEmpty() + events.hasLogMessageEvent() + events.nextLogMessageEvent() == logMessage + !events.hasLogMessageEvent() + events.isEmpty() + + when: + events.addMetricEvent(metric) + + then: + !events.isEmpty() + events.hasMetricEvent() + events.nextMetricEvent() == metric + !events.hasMetricEvent() + events.isEmpty() + } + + def 'noop sink'() { + def sink = EventSink.NOOP + + expect: + sink.addMetricEvent(null) + sink.addLogMessageEvent(null) + sink.addIntegrationEvent(null) + sink.addDistributionSeriesEvent(null) + sink.addDependencyEvent(null) + sink.addConfigChangeEvent(null) + } +} diff --git a/telemetry/src/test/groovy/datadog/telemetry/ExtendedHeartbeatDataSpecification.groovy b/telemetry/src/test/groovy/datadog/telemetry/ExtendedHeartbeatDataSpecification.groovy new file mode 100644 index 00000000000..682df37c2e9 --- /dev/null +++ b/telemetry/src/test/groovy/datadog/telemetry/ExtendedHeartbeatDataSpecification.groovy @@ -0,0 +1,84 @@ +package datadog.telemetry + +import datadog.telemetry.api.Integration +import datadog.telemetry.dependency.Dependency +import datadog.trace.api.ConfigOrigin +import datadog.trace.api.ConfigSetting +import spock.lang.Specification + +class ExtendedHeartbeatDataSpecification extends Specification { + + def dependency = new Dependency("name", "version", "source", "hash") + def configSetting = new ConfigSetting("key", "value", ConfigOrigin.DEFAULT) + def integration = new Integration("integration", true) + + def 'discard dependencies after exceeding limit'() { + setup: + def extHeartbeatData = new ExtendedHeartbeatData(limit) + + when: + (limit + 1).times { + extHeartbeatData.pushDependency(dependency) + } + + then: + def snapshot = extHeartbeatData.snapshot() + int i = 0 + while (snapshot.hasDependencyEvent()) { + snapshot.nextDependencyEvent() + i++ + } + i == limit + + where: + limit << [0, 2, 10] + } + + def 'return all collected data'() { + setup: + def extHeartbeatData = new ExtendedHeartbeatData() + + when: + def s0 = extHeartbeatData.snapshot() + + then: + s0.isEmpty() + + when: + extHeartbeatData.pushDependency(dependency) + extHeartbeatData.pushConfigSetting(configSetting) + extHeartbeatData.pushIntegration(integration) + + then: + def s1 = extHeartbeatData.snapshot() + + !s1.isEmpty() + + s1.hasDependencyEvent() + s1.nextDependencyEvent() == dependency + !s1.hasDependencyEvent() + + !s1.isEmpty() + + s1.hasConfigChangeEvent() + s1.nextConfigChangeEvent() == configSetting + !s1.hasConfigChangeEvent() + + !s1.isEmpty() + + s1.hasIntegrationEvent() + s1.nextIntegrationEvent() == integration + !s1.hasIntegrationEvent() + + s1.isEmpty() + + when: 'another snapshot includes all data' + def s2 = extHeartbeatData.snapshot() + + then: + !s2.isEmpty() + s2.hasDependencyEvent() + s2.hasConfigChangeEvent() + s2.hasIntegrationEvent() + } +} diff --git a/telemetry/src/test/groovy/datadog/telemetry/HttpClientSpecification.groovy b/telemetry/src/test/groovy/datadog/telemetry/HttpClientSpecification.groovy deleted file mode 100644 index 58b6d6a8fb7..00000000000 --- a/telemetry/src/test/groovy/datadog/telemetry/HttpClientSpecification.groovy +++ /dev/null @@ -1,59 +0,0 @@ -package datadog.telemetry - -import okhttp3.Call -import okhttp3.HttpUrl -import okhttp3.MediaType -import okhttp3.OkHttpClient -import okhttp3.Protocol -import okhttp3.Request -import okhttp3.Response -import okhttp3.ResponseBody -import spock.lang.Specification - -class HttpClientSpecification extends Specification { - - def dummyRequest = new Request.Builder().url(HttpUrl.get("https://example.com")).build() - - Call mockResponse(int code) { - Stub(Call) { - execute() >> { - new Response.Builder() - .request(dummyRequest) - .protocol(Protocol.HTTP_1_1) - .message("OK") - .body(ResponseBody.create(MediaType.get("text/plain"), "OK")) - .code(code) - .build() - } - } - } - - OkHttpClient okHttpClient = Mock() - - def httpClient = new HttpClient(okHttpClient) - - def 'map an http status code to the correct send result'() { - when: - def result = httpClient.sendRequest(dummyRequest) - - then: - result == sendResult - 1 * okHttpClient.newCall(_) >> mockResponse(httpCode) - - where: - httpCode | sendResult - 100 | HttpClient.Result.FAILURE - 202 | HttpClient.Result.SUCCESS - 404 | HttpClient.Result.NOT_FOUND - 500 | HttpClient.Result.FAILURE - } - - def 'catch IOException from OkHttpClient and return FAILURE'() { - when: - def result = httpClient.sendRequest(dummyRequest) - - then: - result == HttpClient.Result.FAILURE - 1 * okHttpClient.newCall(_) >> { throw new IOException("exception") } - } -} diff --git a/telemetry/src/test/groovy/datadog/telemetry/RequestBuilderSpecification.groovy b/telemetry/src/test/groovy/datadog/telemetry/RequestBuilderSpecification.groovy deleted file mode 100644 index 9ddb9c35f21..00000000000 --- a/telemetry/src/test/groovy/datadog/telemetry/RequestBuilderSpecification.groovy +++ /dev/null @@ -1,78 +0,0 @@ -package datadog.telemetry - -import datadog.telemetry.api.ConfigChange -import datadog.telemetry.api.RequestType -import okhttp3.HttpUrl -import okhttp3.Request -import okio.Buffer -import spock.lang.Specification - -/** - * This test only verifies non-functional specifics that are not covered in TelemetryServiceSpecification - */ -class RequestBuilderSpecification extends Specification { - final HttpUrl httpUrl = HttpUrl.get("https://example.com") - - def 'throw SerializationException in case of JSON nesting problem'() { - setup: - def b = new RequestBuilder(RequestType.APP_STARTED, httpUrl) - - when: - b.writeHeader() - b.writeHeader() - - then: - RequestBuilder.SerializationException ex = thrown() - ex.message == "Failed serializing Telemetry request header part!" - ex.cause != null - } - - def 'throw SerializationException in case of more than one top-level JSON value'() { - setup: - def b = new RequestBuilder(RequestType.APP_STARTED, httpUrl) - - when: - b.writeHeader() - b.writeFooter() - b.writeHeader() - - then: - RequestBuilder.SerializationException ex = thrown() - ex.message == "Failed serializing Telemetry request header part!" - ex.cause != null - } - - def 'writeConfig must support values of Boolean, String, Integer, Double, Map'() { - setup: - RequestBuilder rb = new RequestBuilder(RequestType.APP_CLIENT_CONFIGURATION_CHANGE, httpUrl) - Map map = new HashMap<>() - map.put("key1", "value1") - map.put("key2", Double.parseDouble("432.32")) - map.put("key3", 324) - - when: - // header needed for a proper JSON - rb.writeHeader() - // but not needed for verification - drainToString(rb.request()) - - then: - rb.writeConfigChangeEvent([ - new ConfigChange("string", "bar"), - new ConfigChange("int", 2342), - new ConfigChange("double", Double.valueOf("123.456")), - new ConfigChange("map", map) - ]) - - then: - drainToString(rb.request()) == '"configuration":[{"name":"string","value":"bar"},{"name":"int","value":2342},{"name":"double","value":123.456},{"name":"map","value":{"key1":"value1","key2":432.32,"key3":324}}]' - } - - String drainToString(Request req) { - Buffer buf = new Buffer() - req.body().writeTo(buf) - byte[] bytes = new byte[buf.size()] - buf.read(bytes) - return new String(bytes) - } -} diff --git a/telemetry/src/test/groovy/datadog/telemetry/TelemetryRequestBodySpecification.groovy b/telemetry/src/test/groovy/datadog/telemetry/TelemetryRequestBodySpecification.groovy new file mode 100644 index 00000000000..18cc79c0e66 --- /dev/null +++ b/telemetry/src/test/groovy/datadog/telemetry/TelemetryRequestBodySpecification.groovy @@ -0,0 +1,97 @@ +package datadog.telemetry + +import datadog.telemetry.api.RequestType +import datadog.trace.api.ConfigOrigin +import datadog.trace.api.ConfigSetting +import okio.Buffer +import okhttp3.RequestBody +import spock.lang.Specification + +/** + * This test only verifies non-functional specifics that are not covered in TelemetryServiceSpecification + */ +class TelemetryRequestBodySpecification extends Specification { + + def 'throw SerializationException in case of JSON nesting problem'() { + setup: + def req = new TelemetryRequestBody(RequestType.APP_STARTED) + + when: + req.beginRequest(false) + req.beginRequest(false) + + then: + TelemetryRequestBody.SerializationException ex = thrown() + ex.message == "Failed serializing Telemetry begin-request part!" + ex.cause != null + } + + def 'throw SerializationException in case of more than one top-level JSON value'() { + setup: + def req = new TelemetryRequestBody(RequestType.APP_STARTED) + + when: + req.beginRequest(false) + req.endRequest() + req.beginRequest(false) + + then: + TelemetryRequestBody.SerializationException ex = thrown() + ex.message == "Failed serializing Telemetry begin-request part!" + ex.cause != null + } + + def 'writeConfig must support values of Boolean, String, Integer, Double, Map'() { + setup: + TelemetryRequestBody req = new TelemetryRequestBody(RequestType.APP_CLIENT_CONFIGURATION_CHANGE) + Map map = new HashMap<>() + map.put("key1", "value1") + map.put("key2", Double.parseDouble("432.32")) + map.put("key3", 324) + + when: + req.beginRequest(false) + // exclude request header to simplify assertion + drainToString(req) + + then: + req.beginConfiguration() + [ + new ConfigSetting("string", "bar", ConfigOrigin.REMOTE), + new ConfigSetting("int", 2342, ConfigOrigin.DEFAULT), + new ConfigSetting("double", Double.valueOf("123.456"), ConfigOrigin.ENV), + new ConfigSetting("map", map, ConfigOrigin.JVM_PROP), + // make sure null values are serialized + new ConfigSetting("null", null, ConfigOrigin.DEFAULT) + ].forEach { cc -> req.writeConfiguration(cc) } + req.endConfiguration() + + then: + drainToString(req) == ',"configuration":[' + + '{"name":"string","value":"bar","origin":"remote_config"},' + + '{"name":"int","value":2342,"origin":"default"},' + + '{"name":"double","value":123.456,"origin":"env_var"},' + + '{"name":"map","value":{"key1":"value1","key2":432.32,"key3":324},"origin":"jvm_prop"},' + + '{"name":"null","value":null,"origin":"default"}]' + } + + def 'add debug flag'() { + setup: + TelemetryRequestBody req = new TelemetryRequestBody(RequestType.APP_STARTED) + + when: + req.beginRequest(true) + req.endRequest() + + then: + drainToString(req).contains("\"debug\":true") + } + + String drainToString(RequestBody body) { + Buffer buf = new Buffer() + body.writeTo(buf) + byte[] bytes = new byte[buf.size()] + buf.read(bytes) + return new String(bytes) + } +} diff --git a/telemetry/src/test/groovy/datadog/telemetry/TelemetryRouterSpecification.groovy b/telemetry/src/test/groovy/datadog/telemetry/TelemetryRouterSpecification.groovy new file mode 100644 index 00000000000..b8cdc2a7c4c --- /dev/null +++ b/telemetry/src/test/groovy/datadog/telemetry/TelemetryRouterSpecification.groovy @@ -0,0 +1,311 @@ +package datadog.telemetry + +import datadog.communication.ddagent.DDAgentFeaturesDiscovery +import datadog.telemetry.api.RequestType +import okhttp3.Call +import okhttp3.HttpUrl +import okhttp3.MediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody +import spock.lang.Specification + +class TelemetryRouterSpecification extends Specification { + + def dummyRequest() { + return new TelemetryRequest(Mock(EventSource), Mock(EventSink), 1000, RequestType.APP_STARTED, false) + } + + Call mockResponse(int code) { + Stub(Call) { + execute() >> { + new Response.Builder() + .request(new Request.Builder().url(HttpUrl.get("https://example.com")).build()) + .protocol(Protocol.HTTP_1_1) + .message("OK") + .body(ResponseBody.create(MediaType.get("text/plain"), "OK")) + .code(code) + .build() + } + } + } + + static HttpUrl agentUrl = HttpUrl.get("https://agent.example.com") + static HttpUrl agentTelemetryUrl = agentUrl.resolve("telemetry/proxy/api/v2/apmtelemetry") + static HttpUrl intakeUrl = HttpUrl.get("https://intake.example.com") + static String apiKey = "api-key" + static String apiKeyHeader = "DD-API-KEY" + + OkHttpClient okHttpClient = Mock() + DDAgentFeaturesDiscovery ddAgentFeaturesDiscovery = Mock() + + def agentTelemetryClient = TelemetryClient.buildAgentClient(okHttpClient, agentUrl) + def intakeTelemetryClient = new TelemetryClient(okHttpClient, intakeUrl, apiKey) + def httpClient = new TelemetryRouter(ddAgentFeaturesDiscovery, agentTelemetryClient, intakeTelemetryClient) + + def 'map an http status code to the correct send result'() { + when: + def result = httpClient.sendRequest(dummyRequest()) + + then: + result == sendResult + 1 * okHttpClient.newCall(_) >> mockResponse(httpCode) + + where: + httpCode | sendResult + 100 | TelemetryClient.Result.FAILURE + 202 | TelemetryClient.Result.SUCCESS + 404 | TelemetryClient.Result.NOT_FOUND + 500 | TelemetryClient.Result.FAILURE + } + + def 'catch IOException from OkHttpClient and return FAILURE'() { + when: + def result = httpClient.sendRequest(dummyRequest()) + + then: + result == TelemetryClient.Result.FAILURE + 1 * okHttpClient.newCall(_) >> { throw new IOException("exception") } + } + + def 'keep trying to send telemetry to Agent despite of return code when Intake client is null'() { + setup: + def httpClient = new TelemetryRouter(ddAgentFeaturesDiscovery, agentTelemetryClient, null) + + Request request + + when: + httpClient.sendRequest(dummyRequest()) + + then: + 1 * ddAgentFeaturesDiscovery.supportsTelemetryProxy() >>> [true, false] + 1 * okHttpClient.newCall(_) >> { args -> request = args[0]; mockResponse(returnCode) } + request.url() == agentTelemetryUrl + request.header(apiKeyHeader) == null + + when: + httpClient.sendRequest(dummyRequest()) + + then: + 1 * ddAgentFeaturesDiscovery.supportsTelemetryProxy() >> [false, true] + 1 * okHttpClient.newCall(_) >> { args -> request = args[0]; mockResponse(returnCode) } + request.url() == agentTelemetryUrl + request.header(apiKeyHeader) == null + + where: + returnCode | _ + 200 | _ + 404 | _ + 500 | _ + } + + def 'switch to Intake when Agent stops supporting telemetry proxy and telemetry requests start failing'() { + Request request + + when: + httpClient.sendRequest(dummyRequest()) + + then: + 1 * ddAgentFeaturesDiscovery.discoverIfOutdated() + 1 * ddAgentFeaturesDiscovery.supportsTelemetryProxy() >> true + 1 * okHttpClient.newCall(_) >> { args -> request = args[0]; mockResponse(returnCode) } + request.url() == agentTelemetryUrl + request.header(apiKeyHeader) == null + + when: + httpClient.sendRequest(dummyRequest()) + + then: + 1 * ddAgentFeaturesDiscovery.discoverIfOutdated() + 1 * ddAgentFeaturesDiscovery.supportsTelemetryProxy() >> false + 1 * okHttpClient.newCall(_) >> { args -> request = args[0]; mockResponse(returnCode) } + request.url() == intakeUrl + request.header(apiKeyHeader) == apiKey + + where: + returnCode | _ + 404 | _ + 500 | _ + } + + def 'do not switch to Intake when Agent stops supporting telemetry proxy but accepts telemetry requests'() { + Request request + + when: + httpClient.sendRequest(dummyRequest()) + + then: + 1 * ddAgentFeaturesDiscovery.discoverIfOutdated() + 1 * ddAgentFeaturesDiscovery.supportsTelemetryProxy() >> true + 1 * okHttpClient.newCall(_) >> { args -> request = args[0]; mockResponse(200) } + request.url() == agentTelemetryUrl + request.header(apiKeyHeader) == null + + when: + httpClient.sendRequest(dummyRequest()) + + then: + 1 * ddAgentFeaturesDiscovery.discoverIfOutdated() + 1 * ddAgentFeaturesDiscovery.supportsTelemetryProxy() >> false + 1 * okHttpClient.newCall(_) >> { args -> request = args[0]; mockResponse(201) } + request.url() == agentTelemetryUrl + request.header(apiKeyHeader) == null + } + + def 'switch to Intake when Agent fails to receive telemetry requests'() { + Request request + + when: + httpClient.sendRequest(dummyRequest()) + + then: + 1 * ddAgentFeaturesDiscovery.discoverIfOutdated() + 1 * ddAgentFeaturesDiscovery.supportsTelemetryProxy() >>> [true, false] + 1 * okHttpClient.newCall(_) >> { args -> request = args[0]; mockResponse(returnCode) } + request.url() == agentTelemetryUrl + request.header(apiKeyHeader) == null + + when: + httpClient.sendRequest(dummyRequest()) + + then: + 1 * ddAgentFeaturesDiscovery.discoverIfOutdated() + 1 * ddAgentFeaturesDiscovery.supportsTelemetryProxy() >>> [false, true] + 1 * okHttpClient.newCall(_) >> { args -> request = args[0]; mockResponse(returnCode) } + request.url() == intakeUrl + request.header(apiKeyHeader) == apiKey + + where: + returnCode | _ + 404 | _ + 500 | _ + } + + def 'use Agent when Intake is not available'() { + setup: + def httpClient = new TelemetryRouter(ddAgentFeaturesDiscovery, agentTelemetryClient, null) + + Request request + + when: + httpClient.sendRequest(dummyRequest()) + + then: + 1 * ddAgentFeaturesDiscovery.discoverIfOutdated() + 1 * ddAgentFeaturesDiscovery.supportsTelemetryProxy() >> false + 1 * okHttpClient.newCall(_) >> { args -> + request = args[0]; mockResponse(returnCode) + } + request.url() == expectedUrl + request.header(apiKeyHeader) == expectedApiKey + + when: + httpClient.sendRequest(dummyRequest()) + + then: + 1 * ddAgentFeaturesDiscovery.discoverIfOutdated() + 1 * ddAgentFeaturesDiscovery.supportsTelemetryProxy() >> false + 1 * okHttpClient.newCall(_) >> { args -> request = args[0]; mockResponse(returnCode) } + request.url() == agentTelemetryUrl + request.header(apiKeyHeader) == null + + when: + httpClient.sendRequest(dummyRequest()) + + then: + 1 * ddAgentFeaturesDiscovery.discoverIfOutdated() + 1 * ddAgentFeaturesDiscovery.supportsTelemetryProxy() >> false + 1 * okHttpClient.newCall(_) >> { args -> request = args[0]; mockResponse(returnCode) } + request.url() == expectedUrl + request.header(apiKeyHeader) == expectedApiKey + + where: + returnCode | expectedApiKey | expectedUrl + 404 | null | agentTelemetryUrl + 500 | null | agentTelemetryUrl + } + + def 'switch to Intake when Agent fails to receive telemetry requests'() { + Request request + + when: + httpClient.sendRequest(dummyRequest()) + + then: 'always send first telemetry request to Agent' + 1 * ddAgentFeaturesDiscovery.discoverIfOutdated() + 1 * ddAgentFeaturesDiscovery.supportsTelemetryProxy() >> false + 1 * okHttpClient.newCall(_) >> { args -> + request = args[0]; mockResponse(returnCode) + } + request.url() == agentTelemetryUrl + request.header(apiKeyHeader) == null + + when: + httpClient.sendRequest(dummyRequest()) + + then: 'switch to Intake if sending a telemetry request to Agent failed or Agent supports telemetry proxy' + 1 * ddAgentFeaturesDiscovery.discoverIfOutdated() + 1 * ddAgentFeaturesDiscovery.supportsTelemetryProxy() >> false + 1 * okHttpClient.newCall(_) >> { args -> request = args[0]; mockResponse(returnCode) } + request.url() == intakeUrl + request.header(apiKeyHeader) == apiKey + + when: + httpClient.sendRequest(dummyRequest()) + + then: 'switch back to Agent if Intake request fails' + 1 * ddAgentFeaturesDiscovery.discoverIfOutdated() + 1 * ddAgentFeaturesDiscovery.supportsTelemetryProxy() >> false + 1 * okHttpClient.newCall(_) >> { args -> request = args[0]; mockResponse(returnCode) } + request.url() == agentTelemetryUrl + request.header(apiKeyHeader) == null + + where: + returnCode | _ + 404 | _ + 500 | _ + } + + def 'switch back to Agent if it starts supporting telemetry'() { + Request request + + when: + httpClient.sendRequest(dummyRequest()) + + then: 'always send first telemetry request to Agent' + 1 * ddAgentFeaturesDiscovery.discoverIfOutdated() + 1 * ddAgentFeaturesDiscovery.supportsTelemetryProxy() >> false + 1 * okHttpClient.newCall(_) >> { args -> + request = args[0]; mockResponse(returnCode) + } + request.url() == agentTelemetryUrl + request.header(apiKeyHeader) == null + + when: + httpClient.sendRequest(dummyRequest()) + + then: 'switch to Intake if sending a telemetry request to Agent failed or Agent supports telemetry proxy' + 1 * ddAgentFeaturesDiscovery.discoverIfOutdated() + 1 * ddAgentFeaturesDiscovery.supportsTelemetryProxy() >> true + 1 * okHttpClient.newCall(_) >> { args -> request = args[0]; mockResponse(201) } + request.url() == intakeUrl + request.header(apiKeyHeader) == apiKey + + when: + httpClient.sendRequest(dummyRequest()) + + then: 'switch back to Agent if it starts supporting telemetry proxy' + 1 * ddAgentFeaturesDiscovery.discoverIfOutdated() + 1 * ddAgentFeaturesDiscovery.supportsTelemetryProxy() >> false + 1 * okHttpClient.newCall(_) >> { args -> request = args[0]; mockResponse(returnCode) } + request.url() == agentTelemetryUrl + request.header(apiKeyHeader) == null + + where: + returnCode | _ + 404 | _ + 500 | _ + } +} diff --git a/telemetry/src/test/groovy/datadog/telemetry/TelemetryRunnableSpecification.groovy b/telemetry/src/test/groovy/datadog/telemetry/TelemetryRunnableSpecification.groovy index 777dd290449..4ba1e110e76 100644 --- a/telemetry/src/test/groovy/datadog/telemetry/TelemetryRunnableSpecification.groovy +++ b/telemetry/src/test/groovy/datadog/telemetry/TelemetryRunnableSpecification.groovy @@ -1,14 +1,15 @@ package datadog.telemetry import datadog.telemetry.metric.MetricPeriodicAction +import datadog.trace.api.config.GeneralConfig import datadog.trace.api.telemetry.MetricCollector import datadog.trace.api.time.TimeSource -import spock.lang.Specification - +import datadog.trace.test.util.DDSpecification +import datadog.trace.util.Strings import java.util.concurrent.CyclicBarrier import java.util.concurrent.TimeUnit -class TelemetryRunnableSpecification extends Specification { +class TelemetryRunnableSpecification extends DDSpecification { static class TickSleeper implements TelemetryRunnable.ThreadSleeper { CyclicBarrier sleeped = new CyclicBarrier(2) @@ -34,6 +35,7 @@ class TelemetryRunnableSpecification extends Specification { void 'happy path'() { setup: + injectEnvConfig(Strings.toEnvVar(GeneralConfig.TELEMETRY_EXTENDED_HEARTBEAT_INTERVAL), "65") TelemetryRunnable.ThreadSleeper sleeperMock = Mock() TickSleeper sleeper = new TickSleeper(delegate: sleeperMock) TimeSource timeSource = Mock() @@ -50,7 +52,8 @@ class TelemetryRunnableSpecification extends Specification { t.start() sleeper.sleeped.await(10, TimeUnit.SECONDS) - then: + then: 'two unsuccessful attempts to send app-started with the following successful attempt' + 3 * telemetryService.sendAppStartedEvent() >>> [false, false, true] 1 * timeSource.getCurrentTimeMillis() >> 60 * 1000 _ * telemetryService.addConfiguration(_) @@ -61,8 +64,8 @@ class TelemetryRunnableSpecification extends Specification { 1 * metricCollector.drain() >> [] 1 * periodicAction.doIteration(telemetryService) - then: - 1 * telemetryService.sendIntervalRequests() + then: 'two partial and one final telemetry data requests' + 3 * telemetryService.sendTelemetryEvents() >>> [true, true, false] 1 * timeSource.getCurrentTimeMillis() >> 60 * 1000 + 1 1 * sleeperMock.sleep(9999) 0 * _ @@ -157,10 +160,25 @@ class TelemetryRunnableSpecification extends Specification { 1 * periodicAction.doIteration(telemetryService) then: - 1 * telemetryService.sendIntervalRequests() + 1 * telemetryService.sendTelemetryEvents() 1 * timeSource.getCurrentTimeMillis() >> 120 * 1000 + 7 1 * sleeperMock.sleep(9993) + when: 'eights iteration (65 seconds, extended-heartbeat)' + sleeper.go.await(5, TimeUnit.SECONDS) + sleeper.sleeped.await(5, TimeUnit.SECONDS) + + then: + 1 * timeSource.getCurrentTimeMillis() >> 125 * 1000 + + then: + 1 * telemetryService.sendExtendedHeartbeat() + + then: + 1 * timeSource.getCurrentTimeMillis() >> 125 * 1000 + 8 + 1 * sleeperMock.sleep(4992) + 0 * _ + when: t.interrupt() t.join() @@ -170,18 +188,42 @@ class TelemetryRunnableSpecification extends Specification { 1 * metricCollector.prepareMetrics() 1 * metricCollector.drain() >> [] 1 * periodicAction.doIteration(telemetryService) - 1 * telemetryService.sendIntervalRequests() + 1 * telemetryService.sendTelemetryEvents() then: - 1 * telemetryService.sendAppClosingRequest() + 1 * telemetryService.sendAppClosingEvent() 0 * _ } + void 'do not reattempt app-started event until next cycle'() { + setup: + TelemetryRunnable.ThreadSleeper sleeperMock = Mock() + TickSleeper sleeper = new TickSleeper(delegate: sleeperMock) + TimeSource timeSource = Mock() + TelemetryService telemetryService = Mock(TelemetryService) + MetricCollector metricCollector = Mock(MetricCollector) + MetricPeriodicAction metricAction = Stub(MetricPeriodicAction) { + collector() >> metricCollector + } + TelemetryRunnable.TelemetryPeriodicAction periodicAction = Mock(TelemetryRunnable.TelemetryPeriodicAction) + TelemetryRunnable runnable = new TelemetryRunnable(telemetryService, [metricAction, periodicAction], sleeper, timeSource) + t = new Thread(runnable) + + when: 'initial iteration before the first sleep (metrics and heartbeat)' + t.start() + sleeper.sleeped.await(10, TimeUnit.SECONDS) + + then: 'three unsuccessful attempts to send app-started (TelemetryRunnable.MAX_APP_STARTED_RETRIES) with following successful attempt' + 3 * telemetryService.sendAppStartedEvent() >>> [false, false, false] + 2 * timeSource.getCurrentTimeMillis() >> 60 * 1000 + 1 * sleeperMock.sleep(10000) + } + void 'scheduler skips metrics intervals'() { setup: TimeSource timeSource = Mock() TickSleeper sleeper = Mock() - TelemetryRunnable.Scheduler scheduler = new TelemetryRunnable.Scheduler(timeSource, sleeper, 60 * 1000, 10 * 1000) + TelemetryRunnable.Scheduler scheduler = new TelemetryRunnable.Scheduler(timeSource, sleeper, 60 * 1000, 10 * 1000, 0) when: 'first iteration' scheduler.init() @@ -230,7 +272,7 @@ class TelemetryRunnableSpecification extends Specification { setup: TimeSource timeSource = Mock() TickSleeper sleeper = Mock() - TelemetryRunnable.Scheduler scheduler = new TelemetryRunnable.Scheduler(timeSource, sleeper, 60 * 1000, 10 * 1000) + TelemetryRunnable.Scheduler scheduler = new TelemetryRunnable.Scheduler(timeSource, sleeper, 60 * 1000, 10 * 1000, 0) when: 'first iteration' scheduler.init() @@ -273,12 +315,13 @@ class TelemetryRunnableSpecification extends Specification { !scheduler.shouldRunHeartbeat() } - void 'scheduler with heartbeat #heartbeatSecs and metrics #metricsSecs'() { + void 'scheduler with heartbeat #heartbeatSecs and metrics #metricsSecs and extended-heartbeat #extHeartbeatSecs'() { setup: TimeSourceAndSleeper timing = new TimeSourceAndSleeper() - TelemetryRunnable.Scheduler scheduler = new TelemetryRunnable.Scheduler(timing, timing, heartbeatSecs * 1000, metricsSecs * 1000) + TelemetryRunnable.Scheduler scheduler = new TelemetryRunnable.Scheduler(timing, timing, heartbeatSecs * 1000, metricsSecs * 1000, extHeartbeatSecs * 1000) def metricsRun = [] def heartbeatsRun = [] + def extHeartbeatsRun = [] when: scheduler.init() @@ -287,6 +330,12 @@ class TelemetryRunnableSpecification extends Specification { iters.times { metricsRun.add(scheduler.shouldRunMetrics()) heartbeatsRun.add(scheduler.shouldRunHeartbeat()) + def runExtHeartbeat = scheduler.shouldRunExtendedHeartbeat() + extHeartbeatsRun.add(runExtHeartbeat) + if (runExtHeartbeat) { + // need to manually advance to retry next iteration if extended-heartbeat request failed + scheduler.scheduleNextExtendedHeartbeat() + } scheduler.sleepUntilNextIteration() } @@ -295,14 +344,16 @@ class TelemetryRunnableSpecification extends Specification { heartbeatsRun.size() == iters metricsRun.count { it } == expectedMetrics heartbeatsRun.count { it } == expectedHeartbeats + extHeartbeatsRun.count { it } == expectedExtHeartbeats where: - heartbeatSecs | metricsSecs | expectedHeartbeats | expectedMetrics | iters - 1 | 1 | 10 | 10 | 10 - 60 | 10 | 2 | 12 | 12 - 10 | 60 | 12 | 2 | 12 - 5 | 3 | 3 | 4 | 6 - 3 | 5 | 4 | 3 | 6 + iters | metricsSecs | heartbeatSecs | extHeartbeatSecs | expectedMetrics | expectedHeartbeats | expectedExtHeartbeats + 10 | 0 | 0 | 0 | 10 | 10 | 10 + 10 | 1 | 1 | 1 | 10 | 10 | 9 + 12 | 10 | 60 | 60 | 12 | 2 | 1 + 12 | 60 | 10 | 10 | 2 | 12 | 11 + 6 | 3 | 5 | 5 | 4 | 3 | 2 + 6 | 5 | 3 | 3 | 3 | 4 | 3 } class TimeSourceAndSleeper implements TimeSource, TelemetryRunnable.ThreadSleeper { diff --git a/telemetry/src/test/groovy/datadog/telemetry/TelemetryServiceSpecification.groovy b/telemetry/src/test/groovy/datadog/telemetry/TelemetryServiceSpecification.groovy index 5177aeae063..4c0d35c4e79 100644 --- a/telemetry/src/test/groovy/datadog/telemetry/TelemetryServiceSpecification.groovy +++ b/telemetry/src/test/groovy/datadog/telemetry/TelemetryServiceSpecification.groovy @@ -3,51 +3,58 @@ package datadog.telemetry import datadog.telemetry.dependency.Dependency import datadog.telemetry.api.Integration import datadog.telemetry.api.DistributionSeries - -import datadog.telemetry.api.ConfigChange import datadog.telemetry.api.LogMessage import datadog.telemetry.api.LogMessageLevel import datadog.telemetry.api.Metric import datadog.telemetry.api.RequestType -import okhttp3.HttpUrl +import datadog.trace.api.ConfigOrigin +import datadog.trace.api.ConfigSetting import spock.lang.Specification class TelemetryServiceSpecification extends Specification { - - TestHttpClient testHttpClient = new TestHttpClient() - - def configuration = ["confkey": "confvalue"] - def confKeyValue = new ConfigChange("confkey", "confvalue") + def confKeyValue = new ConfigSetting("confkey", "confvalue", ConfigOrigin.DEFAULT) + def configuration = [confkey: confKeyValue] def integration = new Integration("integration", true) def dependency = new Dependency("dependency", "1.0.0", "src", "hash") def metric = new Metric().namespace("tracers").metric("metric").points([[1, 2]]).tags(["tag1", "tag2"]) def distribution = new DistributionSeries().namespace("tracers").metric("distro").points([1, 2, 3]).tags(["tag1", "tag2"]).common(false) def logMessage = new LogMessage().message("log-message").tags("tag1:tag2").level(LogMessageLevel.DEBUG).stackTrace("stack-trace").tracerTime(32423) - TelemetryService telemetryService = new TelemetryService(testHttpClient, HttpUrl.get("https://example.com")) - void 'happy path without data'() { + def 'happy path without data'() { + setup: + TestTelemetryRouter testHttpClient = new TestTelemetryRouter() + TelemetryService telemetryService = new TelemetryService(testHttpClient, 10000, false) + when: 'first iteration' - testHttpClient.expectRequests(1, HttpClient.Result.SUCCESS) - telemetryService.sendIntervalRequests() + testHttpClient.expectRequest(TelemetryClient.Result.SUCCESS) + telemetryService.sendAppStartedEvent() then: 'app-started' - testHttpClient.assertRequestBody(RequestType.APP_STARTED) - .assertPayload() - .configuration(null) - .dependencies([]) - .integrations([]) + testHttpClient.assertRequestBody(RequestType.APP_STARTED).assertPayload().products() testHttpClient.assertNoMoreRequests() when: 'second iteration' - testHttpClient.expectRequests(1, HttpClient.Result.SUCCESS) - telemetryService.sendIntervalRequests() + testHttpClient.expectRequest(TelemetryClient.Result.SUCCESS) + telemetryService.sendTelemetryEvents() + + then: 'app-heartbeat only' + testHttpClient.assertRequestBody(RequestType.APP_HEARTBEAT) + testHttpClient.assertNoMoreRequests() + + when: 'third iteration' + testHttpClient.expectRequest(TelemetryClient.Result.SUCCESS) + telemetryService.sendTelemetryEvents() then: 'app-heartbeat only' testHttpClient.assertRequestBody(RequestType.APP_HEARTBEAT) testHttpClient.assertNoMoreRequests() } - void 'happy path with data before app-started'() { + def 'happy path with data'() { + setup: + TestTelemetryRouter testHttpClient = new TestTelemetryRouter() + TelemetryService telemetryService = new TelemetryService(testHttpClient, 10000, false) + when: 'add data before first iteration' telemetryService.addConfiguration(configuration) telemetryService.addIntegration(integration) @@ -57,48 +64,64 @@ class TelemetryServiceSpecification extends Specification { telemetryService.addLogMessage(logMessage) and: 'send messages' - testHttpClient.expectRequests(1, HttpClient.Result.SUCCESS) - telemetryService.sendIntervalRequests() + testHttpClient.expectRequest(TelemetryClient.Result.SUCCESS) + telemetryService.sendAppStartedEvent() then: - testHttpClient.assertRequestBody(RequestType.APP_STARTED) - .assertPayload() + testHttpClient.assertRequestBody(RequestType.APP_STARTED).assertPayload() + .products() .configuration([confKeyValue]) - .dependencies([dependency]) - .integrations([integration]) + + when: + testHttpClient.expectRequest(TelemetryClient.Result.SUCCESS) + telemetryService.sendTelemetryEvents() + + then: + testHttpClient.assertRequestBody(RequestType.MESSAGE_BATCH) + .assertBatch(6) + .assertFirstMessage(RequestType.APP_HEARTBEAT).hasNoPayload() + // no configuration here as it has already been sent with the app-started event + .assertNextMessage(RequestType.APP_INTEGRATIONS_CHANGE).hasPayload().integrations([integration]) + .assertNextMessage(RequestType.APP_DEPENDENCIES_LOADED).hasPayload().dependencies([dependency]) + .assertNextMessage(RequestType.GENERATE_METRICS).hasPayload().namespace("tracers").metrics([metric]) + .assertNextMessage(RequestType.DISTRIBUTIONS).hasPayload().namespace("tracers").distributionSeries([distribution]) + .assertNextMessage(RequestType.LOGS).hasPayload().logs([logMessage]) + .assertNoMoreMessages() testHttpClient.assertNoMoreRequests() - when: 'second iteration' - testHttpClient.expectRequests(4, HttpClient.Result.SUCCESS) - telemetryService.sendIntervalRequests() + when: 'second iteration heartbeat only' + testHttpClient.expectRequest(TelemetryClient.Result.SUCCESS) + telemetryService.sendTelemetryEvents() then: - testHttpClient.assertRequestBody(RequestType.APP_HEARTBEAT) - testHttpClient.assertRequestBody(RequestType.GENERATE_METRICS) - .assertPayload() - .namespace("tracers") - .metrics([metric]) - testHttpClient.assertRequestBody(RequestType.DISTRIBUTIONS) - .assertPayload() - .namespace("tracers") - .distributionSeries([distribution]) - testHttpClient.assertRequestBody(RequestType.LOGS) - .assertPayload() - .logs([logMessage]) + testHttpClient.assertRequestBody(RequestType.APP_HEARTBEAT).assertNoPayload() + testHttpClient.assertNoMoreRequests() + + when: 'third iteration metrics data' + telemetryService.addMetric(metric) + testHttpClient.expectRequest(TelemetryClient.Result.SUCCESS) + telemetryService.sendTelemetryEvents() + + then: + testHttpClient.assertRequestBody(RequestType.MESSAGE_BATCH) + .assertBatch(2) + .assertFirstMessage(RequestType.APP_HEARTBEAT).hasNoPayload() + .assertNextMessage(RequestType.GENERATE_METRICS).hasPayload().namespace("tracers").metrics([metric]) + .assertNoMoreMessages() testHttpClient.assertNoMoreRequests() } - void 'happy path with data after app-started'() { + def 'happy path with data after app-started'() { + setup: + TestTelemetryRouter testHttpClient = new TestTelemetryRouter() + TelemetryService telemetryService = new TelemetryService(testHttpClient, 10000, false) + when: 'send messages' - testHttpClient.expectRequests(1, HttpClient.Result.SUCCESS) - telemetryService.sendIntervalRequests() + testHttpClient.expectRequest(TelemetryClient.Result.SUCCESS) + telemetryService.sendAppStartedEvent() then: - testHttpClient.assertRequestBody(RequestType.APP_STARTED) - .assertPayload() - .configuration(null) - .dependencies([]) - .integrations([]) + testHttpClient.assertRequestBody(RequestType.APP_STARTED).assertPayload().products() testHttpClient.assertNoMoreRequests() when: 'add data after first iteration' @@ -110,92 +133,67 @@ class TelemetryServiceSpecification extends Specification { telemetryService.addLogMessage(logMessage) and: 'send messages' - testHttpClient.expectRequests(7, HttpClient.Result.SUCCESS) - telemetryService.sendIntervalRequests() + testHttpClient.expectRequest(TelemetryClient.Result.SUCCESS) + telemetryService.sendTelemetryEvents() then: - testHttpClient.assertRequestBody(RequestType.APP_HEARTBEAT) - testHttpClient.assertRequestBody(RequestType.APP_CLIENT_CONFIGURATION_CHANGE) - .assertPayload() - .configuration([confKeyValue]) - testHttpClient.assertRequestBody(RequestType.APP_INTEGRATIONS_CHANGE) - .assertPayload() - .integrations([integration]) - testHttpClient.assertRequestBody(RequestType.APP_DEPENDENCIES_LOADED) - .assertPayload() - .dependencies([dependency]) - testHttpClient.assertRequestBody(RequestType.GENERATE_METRICS) - .assertPayload() - .metrics([metric]) - testHttpClient.assertRequestBody(RequestType.DISTRIBUTIONS) - .assertPayload() - .distributionSeries([distribution]) - testHttpClient.assertRequestBody(RequestType.LOGS) - .assertPayload() - .logs([logMessage]) + testHttpClient.assertRequestBody(RequestType.MESSAGE_BATCH) + .assertBatch(7) + .assertFirstMessage(RequestType.APP_HEARTBEAT).hasNoPayload() + .assertNextMessage(RequestType.APP_CLIENT_CONFIGURATION_CHANGE).hasPayload().configuration([confKeyValue]) + .assertNextMessage(RequestType.APP_INTEGRATIONS_CHANGE).hasPayload().integrations([integration]) + .assertNextMessage(RequestType.APP_DEPENDENCIES_LOADED).hasPayload().dependencies([dependency]) + .assertNextMessage(RequestType.GENERATE_METRICS).hasPayload().namespace("tracers").metrics([metric]) + .assertNextMessage(RequestType.DISTRIBUTIONS).hasPayload().namespace("tracers").distributionSeries([distribution]) + .assertNextMessage(RequestType.LOGS).hasPayload().logs([logMessage]) + .assertNoMoreMessages() testHttpClient.assertNoMoreRequests() } - void 'no message before app-started'() { + def 'do not discard data for app-started event until it has been successfully sent'() { + setup: + TestTelemetryRouter testHttpClient = new TestTelemetryRouter() + TelemetryService telemetryService = new TelemetryService(testHttpClient, 10000, false) + telemetryService.addConfiguration(configuration) + when: 'attempt with 404 error' - testHttpClient.expectRequests(1, HttpClient.Result.NOT_FOUND) - telemetryService.sendIntervalRequests() + testHttpClient.expectRequest(TelemetryClient.Result.NOT_FOUND) + !telemetryService.sendAppStartedEvent() then: 'app-started is attempted' - testHttpClient.assertRequestBody(RequestType.APP_STARTED) - .assertPayload() - .configuration(null) - .dependencies([]) - .integrations([]) + testHttpClient.assertRequestBody(RequestType.APP_STARTED).assertPayload().products().configuration([confKeyValue]) testHttpClient.assertNoMoreRequests() when: 'attempt with 500 error' - testHttpClient.expectRequests(1, HttpClient.Result.FAILURE) - telemetryService.sendIntervalRequests() + testHttpClient.expectRequest(TelemetryClient.Result.FAILURE) + !telemetryService.sendAppStartedEvent() then: 'app-started is attempted' - testHttpClient.assertRequestBody(RequestType.APP_STARTED) - .assertPayload() - .configuration(null) - .dependencies([]) - .integrations([]) + testHttpClient.assertRequestBody(RequestType.APP_STARTED).assertPayload().products().configuration([confKeyValue]) testHttpClient.assertNoMoreRequests() - when: 'attempt with unexpected FAILURE (e.g. 100 http status code) (not valid)' - testHttpClient.expectRequests(1, HttpClient.Result.FAILURE) - telemetryService.sendIntervalRequests() + when: 'attempt with unexpected FAILURE (not valid)' + testHttpClient.expectRequest(TelemetryClient.Result.FAILURE) + !telemetryService.sendAppStartedEvent() then: 'app-started is attempted' - testHttpClient.assertRequestBody(RequestType.APP_STARTED) - .assertPayload() - .configuration(null) - .dependencies([]) - .integrations([]) + testHttpClient.assertRequestBody(RequestType.APP_STARTED).assertPayload().products().configuration([confKeyValue]) testHttpClient.assertNoMoreRequests() when: 'attempt with success' - testHttpClient.expectRequests(1, HttpClient.Result.SUCCESS) - telemetryService.sendIntervalRequests() + testHttpClient.expectRequest(TelemetryClient.Result.SUCCESS) + telemetryService.sendAppStartedEvent() - then: - testHttpClient.assertRequestBody(RequestType.APP_STARTED) - .assertPayload() - .configuration(null) - .dependencies([]) - .integrations([]) + then: 'app-started is attempted' + testHttpClient.assertRequestBody(RequestType.APP_STARTED).assertPayload().products().configuration([confKeyValue]) testHttpClient.assertNoMoreRequests() } - void 'NOT_FOUND (e.g. 404 http status code) at #requestType prevents further messages'() { - when: 'initial iteration' - testHttpClient.expectRequests(1, HttpClient.Result.SUCCESS) - telemetryService.sendIntervalRequests() - - then: - testHttpClient.assertRequestBody(RequestType.APP_STARTED) - testHttpClient.assertNoMoreRequests() + def 'resend data on successful attempt after a failure'() { + setup: + TestTelemetryRouter testHttpClient = new TestTelemetryRouter() + TelemetryService telemetryService = new TelemetryService(testHttpClient, 10000, false) - when: 'add data' telemetryService.addConfiguration(configuration) telemetryService.addIntegration(integration) telemetryService.addDependency(dependency) @@ -203,39 +201,104 @@ class TelemetryServiceSpecification extends Specification { telemetryService.addDistributionSeries(distribution) telemetryService.addLogMessage(logMessage) - and: 'send messages' - testHttpClient.expectRequests(prevCalls, HttpClient.Result.SUCCESS) - testHttpClient.expectRequests(1, HttpClient.Result.NOT_FOUND) - telemetryService.sendIntervalRequests() + when: 'attempt with NOT_FOUND error' + testHttpClient.expectRequest(TelemetryClient.Result.NOT_FOUND) + !telemetryService.sendAppStartedEvent() - then: - testHttpClient.assertRequests(prevCalls) - testHttpClient.assertRequestBody(requestType) + then: 'app-started attempted with config' + testHttpClient.assertRequestBody(RequestType.APP_STARTED).assertPayload().products().configuration([confKeyValue]) + testHttpClient.assertNoMoreRequests() - and: 'no further requests after the first 404' + when: 'successful app-started attempt' + testHttpClient.expectRequest(TelemetryClient.Result.SUCCESS) + telemetryService.sendAppStartedEvent() + + then: 'attempt app-started with SUCCESS' + testHttpClient.assertRequestBody(RequestType.APP_STARTED).assertPayload().products().configuration([confKeyValue]) + + when: 'successful batch attempt' + testHttpClient.expectRequest(TelemetryClient.Result.SUCCESS) + telemetryService.sendTelemetryEvents() + + then: 'attempt batch with SUCCESS' + testHttpClient.assertRequestBody(RequestType.MESSAGE_BATCH) + .assertBatch(6) + .assertFirstMessage(RequestType.APP_HEARTBEAT).hasNoPayload() + // no configuration here as it has already been sent with the app-started event + .assertNextMessage(RequestType.APP_INTEGRATIONS_CHANGE).hasPayload().integrations([integration]) + .assertNextMessage(RequestType.APP_DEPENDENCIES_LOADED).hasPayload().dependencies([dependency]) + .assertNextMessage(RequestType.GENERATE_METRICS).hasPayload().namespace("tracers").metrics([metric]) + .assertNextMessage(RequestType.DISTRIBUTIONS).hasPayload().namespace("tracers").distributionSeries([distribution]) + .assertNextMessage(RequestType.LOGS).hasPayload().logs([logMessage]) + .assertNoMoreMessages() testHttpClient.assertNoMoreRequests() - where: - requestType | prevCalls - RequestType.APP_HEARTBEAT | 0 - RequestType.APP_CLIENT_CONFIGURATION_CHANGE | 1 - RequestType.APP_INTEGRATIONS_CHANGE | 2 - RequestType.APP_DEPENDENCIES_LOADED | 3 - RequestType.GENERATE_METRICS | 4 - RequestType.DISTRIBUTIONS | 5 - RequestType.LOGS | 6 + when: 'attempt with NOT_FOUND error' + testHttpClient.expectRequest(TelemetryClient.Result.NOT_FOUND) + telemetryService.sendTelemetryEvents() + + then: 'message-batch attempted with heartbeat' + testHttpClient.assertRequestBody(RequestType.APP_HEARTBEAT).assertNoPayload() + testHttpClient.assertNoMoreRequests() } - void 'FAILURE (e.g. 500 http status code) at #requestType does not prevents further messages'() { - when: 'initial iteration' - testHttpClient.expectRequests(1, HttpClient.Result.SUCCESS) - telemetryService.sendIntervalRequests() + def 'send closing event request'() { + setup: + TestTelemetryRouter testHttpClient = new TestTelemetryRouter() + TelemetryService telemetryService = new TelemetryService(testHttpClient, 10000, false) + + when: + testHttpClient.expectRequest(TelemetryClient.Result.SUCCESS) + telemetryService.sendAppClosingEvent() then: - testHttpClient.assertRequestBody(RequestType.APP_STARTED) + testHttpClient.assertRequestBody(RequestType.APP_CLOSING) testHttpClient.assertNoMoreRequests() + } + + def 'report when both OTel and OT are enabled'() { + setup: + TestTelemetryRouter testHttpClient = new TestTelemetryRouter() + TelemetryService telemetryService = Spy(new TelemetryService(testHttpClient, 1000, false)) + def otel = new Integration("opentelemetry-1", otelEnabled) + def ot = new Integration("opentracing", otEnabled) + + when: + telemetryService.addIntegration(otel) + + then: + 0 * telemetryService.warnAboutExclusiveIntegrations() + + when: + telemetryService.addIntegration(ot) + + then: + warnining * telemetryService.warnAboutExclusiveIntegrations() + + where: + otelEnabled | otEnabled | warnining + true | true | 1 + true | false | 0 + false | true | 0 + false | false | 0 + } + + def 'split telemetry requests if the size above the limit'() { + setup: + TestTelemetryRouter testHttpClient = new TestTelemetryRouter() + TelemetryService telemetryService = new TelemetryService(testHttpClient, 5000, false) + + when: 'send a heartbeat request without telemetry data to measure body size to set stable request size limit' + testHttpClient.expectRequest(TelemetryClient.Result.SUCCESS) + telemetryService.sendTelemetryEvents() + + then: 'get body size' + def bodySize = testHttpClient.assertRequestBody(RequestType.APP_HEARTBEAT).bodySize() + bodySize > 0 + + when: 'sending first part of data' + telemetryService = new TelemetryService(testHttpClient, bodySize + 500, false) - when: 'add data' telemetryService.addConfiguration(configuration) telemetryService.addIntegration(integration) telemetryService.addDependency(dependency) @@ -243,37 +306,104 @@ class TelemetryServiceSpecification extends Specification { telemetryService.addDistributionSeries(distribution) telemetryService.addLogMessage(logMessage) - and: 'send messages' - testHttpClient.expectRequests(prevCalls, HttpClient.Result.SUCCESS) - testHttpClient.expectRequests(1, HttpClient.Result.FAILURE) - testHttpClient.expectRequests(afterCalls, HttpClient.Result.SUCCESS) - telemetryService.sendIntervalRequests() + testHttpClient.expectRequest(TelemetryClient.Result.SUCCESS) + telemetryService.sendTelemetryEvents() + + then: 'attempt with SUCCESS' + testHttpClient.assertRequestBody(RequestType.MESSAGE_BATCH) + .assertBatch(5) + .assertFirstMessage(RequestType.APP_HEARTBEAT).hasNoPayload() + .assertNextMessage(RequestType.APP_CLIENT_CONFIGURATION_CHANGE).hasPayload().configuration([confKeyValue]) + .assertNextMessage(RequestType.APP_INTEGRATIONS_CHANGE).hasPayload().integrations([integration]) + .assertNextMessage(RequestType.APP_DEPENDENCIES_LOADED).hasPayload().dependencies([dependency]) + .assertNextMessage(RequestType.GENERATE_METRICS).hasPayload().namespace("tracers").metrics([metric]) + // no more data fit this message is sent in the next message + .assertNoMoreMessages() + + when: 'sending second part of data' + testHttpClient.expectRequest(TelemetryClient.Result.SUCCESS) + !telemetryService.sendTelemetryEvents() then: - testHttpClient.assertRequests(prevCalls) - testHttpClient.assertRequestBody(requestType) + testHttpClient.assertRequestBody(RequestType.MESSAGE_BATCH) + .assertBatch(3) + .assertFirstMessage(RequestType.APP_HEARTBEAT).hasNoPayload() + .assertNextMessage(RequestType.DISTRIBUTIONS).hasPayload().namespace("tracers").distributionSeries([distribution]) + .assertNextMessage(RequestType.LOGS).hasPayload().logs([logMessage]) + .assertNoMoreMessages() + testHttpClient.assertNoMoreRequests() + } - then: 'requests continue after first 500' - testHttpClient.assertRequests(afterCalls) + def 'send all collected data with extended-heartbeat request every time'() { + setup: + TestTelemetryRouter testHttpClient = new TestTelemetryRouter() + TelemetryService telemetryService = new TelemetryService(testHttpClient, 10000, false) + + telemetryService.addConfiguration(configuration) + telemetryService.addIntegration(integration) + telemetryService.addDependency(dependency) + + when: + testHttpClient.expectRequest(TelemetryClient.Result.SUCCESS) + telemetryService.sendExtendedHeartbeat() + + then: + testHttpClient.assertRequestBody(RequestType.APP_EXTENDED_HEARTBEAT) + .assertPayload() + .configuration([confKeyValue]) + .integrations([integration]) + .dependencies([dependency]) testHttpClient.assertNoMoreRequests() - where: - requestType | prevCalls | afterCalls - RequestType.APP_HEARTBEAT | 0 | 6 - RequestType.APP_CLIENT_CONFIGURATION_CHANGE | 1 | 5 - RequestType.APP_INTEGRATIONS_CHANGE | 2 | 4 - RequestType.APP_DEPENDENCIES_LOADED | 3 | 3 - RequestType.GENERATE_METRICS | 4 | 2 - RequestType.DISTRIBUTIONS | 5 | 1 - RequestType.LOGS | 6 | 0 + when: + telemetryService.addConfiguration(configuration) + telemetryService.addIntegration(integration) + telemetryService.addDependency(dependency) + + testHttpClient.expectRequest(TelemetryClient.Result.SUCCESS) + telemetryService.sendExtendedHeartbeat() + + then: + testHttpClient.assertRequestBody(RequestType.APP_EXTENDED_HEARTBEAT) + .assertPayload() + .configuration([confKeyValue, confKeyValue]) + .integrations([integration, integration]) + .dependencies([dependency, dependency]) + testHttpClient.assertNoMoreRequests() } - void 'Send closing event request'() { + def 'send extended-heartbeat request, even if data already has been sent or attempted as part of another telemetry events'() { + setup: + TestTelemetryRouter testHttpClient = new TestTelemetryRouter() + TelemetryService telemetryService = new TelemetryService(testHttpClient, 10000, false) + + telemetryService.addConfiguration(configuration) + telemetryService.addIntegration(integration) + telemetryService.addDependency(dependency) + when: - testHttpClient.expectRequests(1, HttpClient.Result.SUCCESS) - telemetryService.sendAppClosingRequest() + testHttpClient.expectRequest(resultCode) + telemetryService.sendTelemetryEvents() then: - testHttpClient.assertRequestBody(RequestType.APP_CLOSING) + testHttpClient.assertRequestBody(RequestType.MESSAGE_BATCH) + + when: + testHttpClient.expectRequest(TelemetryClient.Result.SUCCESS) + telemetryService.sendExtendedHeartbeat() + + then: + testHttpClient.assertRequestBody(RequestType.APP_EXTENDED_HEARTBEAT) + .assertPayload() + .configuration([confKeyValue]) + .integrations([integration]) + .dependencies([dependency]) + testHttpClient.assertNoMoreRequests() + + where: + resultCode | _ + TelemetryClient.Result.SUCCESS | _ + TelemetryClient.Result.FAILURE | _ + TelemetryClient.Result.NOT_FOUND | _ } } diff --git a/telemetry/src/test/groovy/datadog/telemetry/TelemetrySystemSpecification.groovy b/telemetry/src/test/groovy/datadog/telemetry/TelemetrySystemSpecification.groovy index 7eeb4608887..f92b4e836a3 100644 --- a/telemetry/src/test/groovy/datadog/telemetry/TelemetrySystemSpecification.groovy +++ b/telemetry/src/test/groovy/datadog/telemetry/TelemetrySystemSpecification.groovy @@ -5,13 +5,14 @@ import datadog.communication.ddagent.SharedCommunicationObjects import datadog.communication.monitor.Monitoring import datadog.telemetry.dependency.DependencyService import datadog.telemetry.dependency.LocationsCollectingTransformer +import datadog.trace.api.config.GeneralConfig +import datadog.trace.test.util.DDSpecification +import datadog.trace.util.Strings import okhttp3.HttpUrl import okhttp3.OkHttpClient -import spock.lang.Specification - import java.lang.instrument.Instrumentation -class TelemetrySystemSpecification extends Specification { +class TelemetrySystemSpecification extends DDSpecification { Instrumentation inst = Mock() void 'installs dependencies transformer'() { @@ -31,7 +32,7 @@ class TelemetrySystemSpecification extends Specification { def depService = Mock(DependencyService) when: - def thread = TelemetrySystem.createTelemetryRunnable(telemetryService, depService) + def thread = TelemetrySystem.createTelemetryRunnable(telemetryService, depService, true) then: thread != null @@ -42,6 +43,8 @@ class TelemetrySystemSpecification extends Specification { void 'start-stop telemetry system'() { setup: + injectEnvConfig(Strings.toEnvVar(GeneralConfig.SITE), "datad0g.com") + injectEnvConfig(Strings.toEnvVar(GeneralConfig.API_KEY), "api-key") def instrumentation = Mock(Instrumentation) when: diff --git a/telemetry/src/test/groovy/datadog/telemetry/TestHttpClient.groovy b/telemetry/src/test/groovy/datadog/telemetry/TestTelemetryRouter.groovy similarity index 51% rename from telemetry/src/test/groovy/datadog/telemetry/TestHttpClient.groovy rename to telemetry/src/test/groovy/datadog/telemetry/TestTelemetryRouter.groovy index 8f0f2cad678..77151821515 100644 --- a/telemetry/src/test/groovy/datadog/telemetry/TestHttpClient.groovy +++ b/telemetry/src/test/groovy/datadog/telemetry/TestTelemetryRouter.groovy @@ -4,33 +4,39 @@ import datadog.communication.ddagent.TracerVersion import datadog.telemetry.dependency.Dependency import datadog.telemetry.api.Integration import datadog.telemetry.api.DistributionSeries - -import datadog.telemetry.api.ConfigChange import datadog.telemetry.api.LogMessage import datadog.telemetry.api.Metric import datadog.telemetry.api.RequestType +import datadog.trace.api.ConfigSetting import groovy.json.JsonSlurper import okhttp3.Request import okio.Buffer -class TestHttpClient extends HttpClient { - private Queue mockResults = new LinkedList<>() +class TestTelemetryRouter extends TelemetryRouter { + private Queue mockResults = new LinkedList<>() private Queue requests = new LinkedList<>() - TestHttpClient() { - super(null) + TestTelemetryRouter() { + super(null, null, null) } @Override - Result sendRequest(Request request) { + TelemetryClient.Result sendRequest(TelemetryRequest request) { if (mockResults.isEmpty()) { throw new IllegalStateException("Unexpected request has been sent. State expectations with `expectRequests` prior sending requests.") } - requests.add(new RequestAssertions(request)) + + def requestBuilder = request.httpRequest() + requestBuilder.url("https://example.com") + requests.add(new RequestAssertions(requestBuilder.build())) return mockResults.poll() } - void expectRequests(int requestNumber, Result mockResult) { + void expectRequest(TelemetryClient.Result mockResult) { + expectRequests(1, mockResult) + } + + void expectRequests(int requestNumber, TelemetryClient.Result mockResult) { for (int i=0; i < requestNumber; i++) { mockResults.add(mockResult) } @@ -46,15 +52,8 @@ class TestHttpClient extends HttpClient { return this.requests.poll() } - void assertRequests(int numberOfRequests) { - for (int i=0; i < numberOfRequests; i++) { - assertRequest() - } - } - BodyAssertions assertRequestBody(RequestType rt) { - return assertRequest().headers(rt) - .assertBody().commonParts(rt) + return assertRequest().headers(rt).assertBody().commonParts(rt) } void assertNoMoreRequests() { @@ -76,40 +75,49 @@ class TestHttpClient extends HttpClient { } RequestAssertions headers(RequestType requestType) { - assert request.method() == 'POST' - assert request.headers().names() == [ + assert this.request.method() == 'POST' + assert this.request.headers().names() == [ 'Content-Type', + 'Content-Length', 'DD-Client-Library-Language', 'DD-Client-Library-Version', 'DD-Telemetry-API-Version', 'DD-Telemetry-Request-Type' ] as Set - assert request.header('Content-Type') == 'application/json; charset=utf-8' - assert request.header('DD-Client-Library-Language') == 'jvm' - assert request.header('DD-Client-Library-Version') == TracerVersion.TRACER_VERSION - assert request.header('DD-Telemetry-API-Version') == 'v1' - assert request.header('DD-Telemetry-Request-Type') == requestType.toString() + assert this.request.header('Content-Type') == 'application/json; charset=utf-8' + assert this.request.header('Content-Length').toInteger() > 0 + assert this.request.header('DD-Client-Library-Language') == 'jvm' + assert this.request.header('DD-Client-Library-Version') == TracerVersion.TRACER_VERSION + assert this.request.header('DD-Telemetry-API-Version') == 'v2' + assert this.request.header('DD-Telemetry-Request-Type') == requestType.toString() return this } BodyAssertions assertBody() { Buffer buf = new Buffer() - request.body().writeTo(buf) + this.request.body().writeTo(buf) byte[] bytes = new byte[buf.size()] buf.read(bytes) - return new BodyAssertions(SLURPER.parse(bytes) as Map) + def parsed = SLURPER.parse(bytes) as Map + return new BodyAssertions(parsed, bytes) } } static class BodyAssertions { - private Map body + private final Map body + private final byte[] bodyBytes - BodyAssertions(Map body) { + BodyAssertions(Map body, byte[] bodyBytes) { this.body = body + this.bodyBytes = bodyBytes + } + + int bodySize() { + return this.bodyBytes.length } BodyAssertions commonParts(RequestType requestType) { - assert body['api_version'] == 'v1' + assert body['api_version'] == 'v2' def app = body['application'] assert app['env'] != null @@ -136,34 +144,114 @@ class TestHttpClient extends HttpClient { } PayloadAssertions assertPayload() { - return new PayloadAssertions(body['payload'] as Map) + def payload = body['payload'] as Map + assert payload != null + return new PayloadAssertions(payload) + } + + BatchAssertions assertBatch(int expectedNumberOfPayloads) { + List> payloads = body['payload'] + assert payloads != null && payloads.size() == expectedNumberOfPayloads + return new BatchAssertions(payloads) + } + + void assertNoPayload() { + assert body['payload'] == null + } + } + + static class BatchAssertions { + private List> messages + + BatchAssertions(List> messages) { + this.messages = messages + } + + BatchMessageAssertions assertFirstMessage(RequestType expected) { + return assertMessage(0, expected) + } + + private BatchMessageAssertions assertMessage(int index, RequestType expected) { + if (index > messages.size()) { + throw new IllegalStateException("Asserted more messages than available (${messages.size()}) in the batch") + } + def message = messages[index] + assert message['request_type'] == String.valueOf(expected) + return new BatchMessageAssertions(this, index, message) + } + } + + static class BatchMessageAssertions { + private BatchAssertions batchAssertions + private int messageIndex + private Map message + + BatchMessageAssertions(BatchAssertions batchAssertions, int messageIndex, Map message) { + this.batchAssertions = batchAssertions + this.messageIndex = messageIndex + this.message = message + } + + BatchMessageAssertions hasNoPayload() { + assert message['payload'] == null + return this + } + + BatchMessageAssertions assertNextMessage(RequestType expected) { + messageIndex += 1 + if (messageIndex >= batchAssertions.messages.size()) { + throw new IllegalStateException("No more messages available") + } + return batchAssertions.assertMessage(messageIndex, expected) + } + + PayloadAssertions hasPayload() { + def payload = message['payload'] as Map + assert payload != null + return new PayloadAssertions(payload, this) + } + + void assertNoMoreMessages() { + assert messageIndex == batchAssertions.messages.size() - 1 } } static class PayloadAssertions { private Map payload + private BatchMessageAssertions batch PayloadAssertions(Map payload) { + this(payload, null) + } + + PayloadAssertions(Map payload, BatchMessageAssertions batch) { this.payload = payload + this.batch = batch } - PayloadAssertions configuration(List configuration) { + PayloadAssertions configuration(List configuration) { def expected = configuration == null ? null : [] if (configuration != null) { - for (ConfigChange kv : configuration) { - expected.add([name: kv.name, value: kv.value]) + for (ConfigSetting cs : configuration) { + expected.add([name: cs.key, value: cs.value, origin: cs.origin.value]) } } - assert payload['configuration'] == expected + assert this.payload['configuration'] == expected + return this + } + + PayloadAssertions products(boolean appsecEnabled = true, boolean profilerEnabled = false) { + def expected = [appsec: [enabled: appsecEnabled], profiler: [enabled: profilerEnabled]] + assert this.payload['products'] == expected return this } PayloadAssertions dependencies(List dependencies) { def expected = [] for (Dependency d : dependencies) { - expected.add([hash: d.hash, name: d.name, type: "PlatformStandard", version: d.version]) + expected.add([hash: d.hash, name: d.name, version: d.version]) } - assert payload['dependencies'] == expected + assert this.payload['dependencies'] == expected return this } @@ -175,12 +263,12 @@ class TestHttpClient extends HttpClient { map.put("name", i.name) expected.add(map) } - assert payload['integrations'] == expected + assert this.payload['integrations'] == expected return this } PayloadAssertions namespace(String namespace) { - assert payload['namespace'] == namespace + assert this.payload['namespace'] == namespace return this } @@ -204,7 +292,7 @@ class TestHttpClient extends HttpClient { obj.put("tags", m.getTags()) expected.add(obj) } - assert payload['series'] == expected + assert this.payload['series'] == expected return this } @@ -221,7 +309,7 @@ class TestHttpClient extends HttpClient { obj.put("tags", d.getTags()) expected.add(obj) } - assert payload['series'] == expected + assert this.payload['series'] == expected return this } @@ -240,8 +328,16 @@ class TestHttpClient extends HttpClient { } expected.add(map) } - assert payload['logs'] == expected + assert this.payload['logs'] == expected return this } + + BatchMessageAssertions assertNextMessage(RequestType requestType) { + return batch.assertNextMessage(requestType) + } + + void assertNoMoreMessages() { + batch.assertNoMoreMessages() + } } }