diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 08391601dfa..ca5a947885d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1115,7 +1115,7 @@ test_debugger_arm64: - !reference [.test_job_arm64, script] test_smoke: - extends: .test_job + extends: .test_job_with_test_agent # needs:parallel:matrix limits this job to a specific build_tests combination. # Keep matrix vars exact and in build_tests declaration order: # https://docs.gitlab.com/ci/yaml/#needsparallelmatrix @@ -1135,7 +1135,7 @@ test_smoke: matrix: *test_matrix_8 test_smoke_arm64: - extends: .test_job_arm64 + extends: .test_job_arm64_with_test_agent variables: <<: *tier_l_variables GRADLE_TARGET: "stageMainDist :smokeTest" diff --git a/communication/src/main/java/datadog/communication/ddagent/SharedCommunicationObjects.java b/communication/src/main/java/datadog/communication/ddagent/SharedCommunicationObjects.java index 9468a8a1f93..73e9fbc037c 100644 --- a/communication/src/main/java/datadog/communication/ddagent/SharedCommunicationObjects.java +++ b/communication/src/main/java/datadog/communication/ddagent/SharedCommunicationObjects.java @@ -26,6 +26,8 @@ public class SharedCommunicationObjects { private static final Logger log = LoggerFactory.getLogger(SharedCommunicationObjects.class); + private static final String X_DATADOG_TEST_SESSION_TOKEN = "X-Datadog-Test-Session-Token"; + private final List pausedComponents = new ArrayList<>(); private volatile boolean paused; @@ -90,9 +92,27 @@ public void createRemaining(Config config) { agentHttpClient = OkHttpUtils.buildHttpClient( OkHttpUtils.isPlainHttp(agentUrl), unixDomainSocket, namedPipe, httpClientTimeout); + String testSessionToken = config.getTestAgentSessionToken(); + if (testSessionToken != null) { + agentHttpClient = injectTestAgentSessionHeaderInterceptor(testSessionToken); + } } } + private OkHttpClient injectTestAgentSessionHeaderInterceptor(String testSessionToken) { + return agentHttpClient + .newBuilder() + .addInterceptor( + chain -> + chain.proceed( + chain + .request() + .newBuilder() + .header(X_DATADOG_TEST_SESSION_TOKEN, testSessionToken) + .build())) + .build(); + } + /** Registers a callback to be called when remote communications resume. */ public void whenReady(Runnable callback) { if (paused) { diff --git a/dd-smoke-tests/appsec/springboot/src/test/groovy/datadog/smoketest/appsec/ExtendedDataCollectionSmokeTest.groovy b/dd-smoke-tests/appsec/springboot/src/test/groovy/datadog/smoketest/appsec/ExtendedDataCollectionSmokeTest.groovy index dc97e26ba11..99cfbf73327 100644 --- a/dd-smoke-tests/appsec/springboot/src/test/groovy/datadog/smoketest/appsec/ExtendedDataCollectionSmokeTest.groovy +++ b/dd-smoke-tests/appsec/springboot/src/test/groovy/datadog/smoketest/appsec/ExtendedDataCollectionSmokeTest.groovy @@ -303,7 +303,7 @@ class ExtendedDataCollectionSmokeTest extends AbstractAppSecServerSmokeTest { } assert trigger != null, 'test trigger not found' - rootSpan.span.metaStruct != null + !rootSpan.span.metaStruct.isEmpty() def requestBody = rootSpan.span.metaStruct.get('http.request.body') assert requestBody != null, 'request body is not set' !rootSpan.meta.containsKey('_dd.appsec.request_body_size.exceeded') @@ -343,7 +343,7 @@ class ExtendedDataCollectionSmokeTest extends AbstractAppSecServerSmokeTest { } assert trigger != null, 'test trigger not found' - rootSpan.span.metaStruct != null + !rootSpan.span.metaStruct.isEmpty() def requestBody = rootSpan.span.metaStruct.get('http.request.body') assert requestBody != null, 'request body is not set' !rootSpan.meta.containsKey('_dd.appsec.request_body_size.exceeded') @@ -383,7 +383,7 @@ class ExtendedDataCollectionSmokeTest extends AbstractAppSecServerSmokeTest { } assert trigger != null, 'test trigger not found' - rootSpan.span.metaStruct != null + !rootSpan.span.metaStruct.isEmpty() def requestBody = rootSpan.span.metaStruct.get('http.request.body') assert requestBody != null, 'request body is not set' rootSpan.meta.containsKey('_dd.appsec.request_body_size.exceeded') @@ -421,7 +421,7 @@ class ExtendedDataCollectionSmokeTest extends AbstractAppSecServerSmokeTest { } assert trigger == null, 'test trigger found' - rootSpan.span.metaStruct == null + rootSpan.span.metaStruct.isEmpty() } void 'test request body collection if WAF event with default-config'(){ @@ -457,7 +457,7 @@ class ExtendedDataCollectionSmokeTest extends AbstractAppSecServerSmokeTest { } assert trigger != null, 'test trigger not found' - rootSpan.span.metaStruct != null + !rootSpan.span.metaStruct.isEmpty() def requestBody = rootSpan.span.metaStruct.get('http.request.body') assert requestBody != null, 'request body is not set' !rootSpan.meta.containsKey('_dd.appsec.request_body_size.exceeded') diff --git a/dd-smoke-tests/appsec/springboot/src/test/groovy/datadog/smoketest/appsec/SpringBootSmokeTest.groovy b/dd-smoke-tests/appsec/springboot/src/test/groovy/datadog/smoketest/appsec/SpringBootSmokeTest.groovy index dc573ec37ff..f5ad2d20bdd 100644 --- a/dd-smoke-tests/appsec/springboot/src/test/groovy/datadog/smoketest/appsec/SpringBootSmokeTest.groovy +++ b/dd-smoke-tests/appsec/springboot/src/test/groovy/datadog/smoketest/appsec/SpringBootSmokeTest.groovy @@ -699,7 +699,7 @@ class SpringBootSmokeTest extends AbstractAppSecServerSmokeTest { } } assert trigger != null, 'test trigger not found' - rootSpan.span.metaStruct != null + !rootSpan.span.metaStruct.isEmpty() def stack = rootSpan.span.metaStruct.get('_dd.stack') assert stack != null, 'stack is not set' def exploit = stack.get('exploit') @@ -774,7 +774,7 @@ class SpringBootSmokeTest extends AbstractAppSecServerSmokeTest { } } assert trigger != null, 'test trigger not found' - rootSpan.span.metaStruct == null + rootSpan.span.metaStruct.isEmpty() where: variant | _ @@ -814,7 +814,7 @@ class SpringBootSmokeTest extends AbstractAppSecServerSmokeTest { } } assert trigger != null, 'test trigger not found' - rootSpan.span.metaStruct == null + rootSpan.span.metaStruct.isEmpty() where: variant | _ @@ -853,7 +853,7 @@ class SpringBootSmokeTest extends AbstractAppSecServerSmokeTest { } } assert trigger != null, 'test trigger not found' - rootSpan.span.metaStruct == null + rootSpan.span.metaStruct.isEmpty() } def findFirstMatchingSpan(String resource) { @@ -939,7 +939,7 @@ class SpringBootSmokeTest extends AbstractAppSecServerSmokeTest { } } assert trigger != null, 'test trigger not found' - rootSpan.span.metaStruct == null + rootSpan.span.metaStruct.isEmpty() where: endpoint | cmd | params @@ -990,7 +990,7 @@ class SpringBootSmokeTest extends AbstractAppSecServerSmokeTest { } } assert trigger != null, 'test trigger not found' - rootSpan.span.metaStruct == null + rootSpan.span.metaStruct.isEmpty() where: endpoint | cmd | params diff --git a/dd-trace-api/src/main/java/datadog/trace/api/config/TracerConfig.java b/dd-trace-api/src/main/java/datadog/trace/api/config/TracerConfig.java index 9faf4f4ea8e..0ab030189b5 100644 --- a/dd-trace-api/src/main/java/datadog/trace/api/config/TracerConfig.java +++ b/dd-trace-api/src/main/java/datadog/trace/api/config/TracerConfig.java @@ -32,6 +32,7 @@ public final class TracerConfig { public static final String PROXY_NO_PROXY = "proxy.no_proxy"; public static final String TRACE_AGENT_PATH = "trace.agent.path"; public static final String TRACE_AGENT_ARGS = "trace.agent.args"; + public static final String TEST_AGENT_SESSION_TOKEN = "test.agent.session.token"; public static final String PRIORITY_SAMPLING = "priority.sampling"; public static final String PRIORITY_SAMPLING_FORCE = "priority.sampling.force"; @Deprecated public static final String TRACE_RESOLVER_ENABLED = "trace.resolver.enabled"; 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 35edef3fae0..5ce6c62d0a8 100644 --- a/internal-api/src/main/java/datadog/trace/api/Config.java +++ b/internal-api/src/main/java/datadog/trace/api/Config.java @@ -674,6 +674,7 @@ import static datadog.trace.api.config.TracerConfig.SPAN_SAMPLING_RULES_FILE; import static datadog.trace.api.config.TracerConfig.SPAN_TAGS; import static datadog.trace.api.config.TracerConfig.SPLIT_BY_TAGS; +import static datadog.trace.api.config.TracerConfig.TEST_AGENT_SESSION_TOKEN; import static datadog.trace.api.config.TracerConfig.TRACE_128_BIT_TRACEID_GENERATION_ENABLED; import static datadog.trace.api.config.TracerConfig.TRACE_AGENT_ARGS; import static datadog.trace.api.config.TracerConfig.TRACE_AGENT_PATH; @@ -1389,6 +1390,7 @@ public static String getHostName() { private final boolean azureFunctions; private final boolean awsServerless; private final String traceAgentPath; + private final String testAgentSessionToken; private final List traceAgentArgs; private final String dogStatsDPath; private final List dogStatsDArgs; @@ -3199,6 +3201,7 @@ PROFILING_DATADOG_PROFILER_ENABLED, isDatadogProfilerSafeInCurrentEnvironment()) azureAppServices = configProvider.getBoolean(AZURE_APP_SERVICES, false); traceAgentPath = configProvider.getString(TRACE_AGENT_PATH); + testAgentSessionToken = configProvider.getString(TEST_AGENT_SESSION_TOKEN); String traceAgentArgsString = configProvider.getString(TRACE_AGENT_ARGS); if (traceAgentArgsString == null) { traceAgentArgs = Collections.emptyList(); @@ -5186,6 +5189,10 @@ public String getTraceAgentPath() { return traceAgentPath; } + public String getTestAgentSessionToken() { + return testAgentSessionToken; + } + public List getTraceAgentArgs() { return traceAgentArgs; } diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json index 4dd564d0a53..2d3d77ec355 100644 --- a/metadata/supported-configurations.json +++ b/metadata/supported-configurations.json @@ -4121,6 +4121,14 @@ "aliases": [] } ], + "DD_TEST_AGENT_SESSION_TOKEN": [ + { + "version": "A", + "type": "string", + "default": null, + "aliases": [] + } + ], "DD_TEST_FAILED_TEST_REPLAY_ENABLED": [ { "version": "A", diff --git a/utils/config-utils/src/main/java/datadog/trace/api/ConfigSetting.java b/utils/config-utils/src/main/java/datadog/trace/api/ConfigSetting.java index cf77e3bfb35..35f27491157 100644 --- a/utils/config-utils/src/main/java/datadog/trace/api/ConfigSetting.java +++ b/utils/config-utils/src/main/java/datadog/trace/api/ConfigSetting.java @@ -25,7 +25,13 @@ public final class ConfigSetting { private static final Set CONFIG_FILTER_LIST = new HashSet<>( - Arrays.asList("DD_API_KEY", "dd.api-key", "dd.profiling.api-key", "dd.profiling.apikey")); + Arrays.asList( + "DD_API_KEY", + "dd.api-key", + "dd.profiling.api-key", + "dd.profiling.apikey", + "test.agent.session.token", + "DD_TEST_AGENT_SESSION_TOKEN")); public static ConfigSetting of(String key, Object value, ConfigOrigin origin) { return new ConfigSetting(key, value, origin, ABSENT_SEQ_ID, null); diff --git a/utils/config-utils/src/test/java/datadog/trace/api/ConfigSettingTest.java b/utils/config-utils/src/test/java/datadog/trace/api/ConfigSettingTest.java index 018f108b44f..1d110be1287 100644 --- a/utils/config-utils/src/test/java/datadog/trace/api/ConfigSettingTest.java +++ b/utils/config-utils/src/test/java/datadog/trace/api/ConfigSettingTest.java @@ -44,12 +44,14 @@ void supportsEqualityCheck( } @TableTest({ - "scenario | key | value | filteredValue", - "DD_API_KEY | DD_API_KEY | somevalue | ", - "dd.api-key | dd.api-key | somevalue | ", - "dd.profiling.api-key | dd.profiling.api-key | somevalue | ", - "dd.profiling.apikey | dd.profiling.apikey | somevalue | ", - "some.other.key | some.other.key | somevalue | somevalue " + "scenario | key | value | filteredValue", + "DD_API_KEY | DD_API_KEY | somevalue | ", + "dd.api-key | dd.api-key | somevalue | ", + "dd.profiling.api-key | dd.profiling.api-key | somevalue | ", + "dd.profiling.apikey | dd.profiling.apikey | somevalue | ", + "session token prop | test.agent.session.token | somevalue | ", + "session token env | DD_TEST_AGENT_SESSION_TOKEN | somevalue | ", + "some.other.key | some.other.key | somevalue | somevalue " }) void filtersKeyValues(String key, String value, String filteredValue) { assertEquals(filteredValue, ConfigSetting.of(key, value, ConfigOrigin.DEFAULT).stringValue()); diff --git a/utils/test-agent-utils/decoder/build.gradle.kts b/utils/test-agent-utils/decoder/build.gradle.kts index 844786b398b..11af097d6ce 100644 --- a/utils/test-agent-utils/decoder/build.gradle.kts +++ b/utils/test-agent-utils/decoder/build.gradle.kts @@ -8,10 +8,12 @@ extra["excludedClassesCoverage"] = listOf( "datadog.trace.test.agent.decoder.v04.raw.*", "datadog.trace.test.agent.decoder.v05.raw.*", "datadog.trace.test.agent.decoder.v1.raw.*", + "datadog.trace.test.agent.decoder.json.raw.*", ) dependencies { implementation(group = "org.msgpack", name = "msgpack-core", version = "0.8.24") + implementation(libs.moshi) testImplementation(libs.bundles.junit5) testImplementation(project(":utils:test-utils")) diff --git a/utils/test-agent-utils/decoder/gradle.lockfile b/utils/test-agent-utils/decoder/gradle.lockfile index 95b25904d6a..4826a29c7d0 100644 --- a/utils/test-agent-utils/decoder/gradle.lockfile +++ b/utils/test-agent-utils/decoder/gradle.lockfile @@ -12,6 +12,8 @@ com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs com.google.code.findbugs:jsr305:3.0.2=compileClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath com.google.code.gson:gson:2.13.2=spotbugs com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.squareup.moshi:moshi:1.11.0=compileClasspath,testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio:1.17.5=compileClasspath,testCompileClasspath,testRuntimeClasspath com.thoughtworks.qdox:qdox:1.12.1=codenarc commons-fileupload:commons-fileupload:1.5=testCompileClasspath,testRuntimeClasspath commons-io:commons-io:2.11.0=testCompileClasspath,testRuntimeClasspath @@ -29,7 +31,7 @@ org.apache.commons:commons-lang3:3.19.0=spotbugs org.apache.commons:commons-text:1.14.0=spotbugs org.apache.logging.log4j:log4j-api:2.25.2=spotbugs org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath,testRuntimeClasspath org.codehaus.groovy:groovy-ant:3.0.23=codenarc org.codehaus.groovy:groovy-docgenerator:3.0.23=codenarc org.codehaus.groovy:groovy-groovydoc:3.0.23=codenarc diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/Decoder.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/Decoder.java index 0cbde122e85..313584518a3 100644 --- a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/Decoder.java +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/Decoder.java @@ -1,5 +1,6 @@ package datadog.trace.test.agent.decoder; +import datadog.trace.test.agent.decoder.json.raw.MessageJson; import datadog.trace.test.agent.decoder.v04.raw.MessageV04; import datadog.trace.test.agent.decoder.v05.raw.MessageV05; import datadog.trace.test.agent.decoder.v1.raw.MessageV1; @@ -12,6 +13,11 @@ public static DecodedMessage decodeV1(byte[] buffer) { return MessageV1.unpack(buffer); } + /** Decodes the JSON trace format exposed by the dd-apm-test-agent. */ + public static DecodedMessage decodeJson(String json) { + return MessageJson.fromJson(json); + } + public static DecodedMessage decodeV05(byte[] buffer) { return MessageV05.unpack(buffer); } diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/MessageJson.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/MessageJson.java new file mode 100644 index 00000000000..e76611f3db8 --- /dev/null +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/MessageJson.java @@ -0,0 +1,194 @@ +package datadog.trace.test.agent.decoder.json.raw; + +import static java.util.Collections.unmodifiableList; + +import com.squareup.moshi.FromJson; +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.JsonDataException; +import com.squareup.moshi.JsonReader; +import com.squareup.moshi.JsonWriter; +import com.squareup.moshi.Moshi; +import com.squareup.moshi.ToJson; +import com.squareup.moshi.Types; +import datadog.trace.test.agent.decoder.DecodedMessage; +import datadog.trace.test.agent.decoder.DecodedSpan; +import datadog.trace.test.agent.decoder.DecodedTrace; +import java.io.IOException; +import java.lang.reflect.Type; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * MessageJson decodes a JSON trace payload — a JSON array of traces, each a JSON array of spans in + * the v0.4 shape, as exposed by the dd-apm-test-agent — into the shared {@link DecodedMessage} + * model. Unlike the msgpack formats there is no message envelope, so the payload maps directly to + * the list of traces. + */ +public final class MessageJson implements DecodedMessage { + private static final Type LIST_OF_TRACES = + Types.newParameterizedType( + List.class, Types.newParameterizedType(List.class, SpanJson.class)); + private static final JsonAdapter>> ADAPTER = + new Moshi.Builder() + .add(new MetricNumberAdapter()) + .add(new MetaStructObjectAdapter()) + .build() + .adapter(LIST_OF_TRACES); + + private final List traces; + + private MessageJson(List traces) { + this.traces = unmodifiableList(traces); + } + + /** Decodes a JSON trace payload into a {@link MessageJson}. */ + public static MessageJson fromJson(String json) { + List> rawTraces; + try { + rawTraces = ADAPTER.fromJson(json); + } catch (IOException | JsonDataException e) { + throw new IllegalStateException("Failed to parse JSON traces: " + json, e); + } + List traces = new ArrayList<>(); + if (rawTraces != null) { + for (List spans : rawTraces) { + if (spans == null) { + throw new IllegalStateException("Malformed JSON trace with a null trace: " + json); + } + List decodedSpans = new ArrayList<>(spans.size()); + for (SpanJson span : spans) { + if (span == null) { + throw new IllegalStateException("Malformed JSON trace with a null span: " + json); + } + if (span.name == null + || span.traceId == null + || span.spanId == null + || span.start == null + || span.duration == null) { + throw new IllegalStateException( + "JSON span missing a required v0.4 field " + + "(name, trace_id, span_id, start, duration): " + + span); + } + decodedSpans.add(span); + } + traces.add(new TraceJson(decodedSpans)); + } + } + return new MessageJson(traces); + } + + /** + * Decodes the numeric values of the {@code metrics} map. Moshi coerces every JSON number to + * {@code Double}; this adapter instead reads the number from its literal form and preserves the + * integral vs. fractional distinction the msgpack decoders produce (see {@code + * SpanV04.unpackNumber}): integral values become {@code Integer} (or {@code Long} when they + * overflow {@code int}, or {@code BigInteger} beyond {@code long} range), fractional values + * become {@code Double}. Reading the literal rather than coercing through {@code double} keeps + * every integral value exact — including those above 2^53 and above {@code Long.MAX_VALUE}. + */ + static final class MetricNumberAdapter { + @FromJson + Number fromJson(JsonReader reader) throws IOException { + String literal = reader.nextString(); + boolean fractional = + literal.indexOf('.') >= 0 || literal.indexOf('e') >= 0 || literal.indexOf('E') >= 0; + if (!fractional) { + try { + return Integer.valueOf(literal); + } catch (NumberFormatException overflowsInt) { + try { + return Long.valueOf(literal); + } catch (NumberFormatException overflowsLong) { + // Integral magnitude beyond long range: keep it exact as a BigInteger. + return new BigInteger(literal); + } + } + } + return Double.valueOf(literal); + } + + @ToJson + void toJson(JsonWriter writer, Number value) throws IOException { + writer.value(value); + } + } + + /** + * Decodes the arbitrarily-nested values of the {@code meta_struct} map. Moshi's generic {@code + * Object} adapter materializes every JSON number as {@code Double}, every object as a map, and + * every array as a list; this adapter instead mirrors the msgpack decoder (see {@code + * SpanV04.convertValueToObject}) so both backends expose the same types for a nested leaf: + * + *
    + *
  • integers become {@code Long} (reading the literal keeps values above 2^53 exact, and + * values beyond {@code long} range become {@code BigInteger} rather than a lossy {@code + * Double}), + *
  • floating-point numbers become {@code Float}, + *
  • objects become {@code Map} and arrays {@code List}, recursively, + *
  • strings, booleans, and JSON {@code null} are preserved as {@code String}/{@code + * Boolean}/{@code null}. + * + * + *

    Keeping the representations identical means a {@code metaStruct(...)} assertion behaves the + * same whether the span came from the in-process msgpack backend or the dd-apm-test-agent JSON + * backend. Binary leaves ({@code byte[]} on the msgpack side) have no JSON representation — the + * agent fails to serialize them (HTTP 400) so they never reach this decoder; in practice {@code + * meta_struct} carries only JSON-serializable data. + */ + static final class MetaStructObjectAdapter { + @FromJson + Object fromJson(JsonReader reader) throws IOException { + switch (reader.peek()) { + case BEGIN_OBJECT: + Map object = new LinkedHashMap<>(); + reader.beginObject(); + while (reader.hasNext()) { + object.put(reader.nextName(), fromJson(reader)); + } + reader.endObject(); + return object; + case BEGIN_ARRAY: + List array = new ArrayList<>(); + reader.beginArray(); + while (reader.hasNext()) { + array.add(fromJson(reader)); + } + reader.endArray(); + return array; + case STRING: + return reader.nextString(); + case NUMBER: + return parseNumber(reader.nextString()); + case BOOLEAN: + return reader.nextBoolean(); + case NULL: + return reader.nextNull(); + default: + throw new JsonDataException("Unexpected meta_struct token: " + reader.peek()); + } + } + + private static Number parseNumber(String literal) { + boolean fractional = + literal.indexOf('.') >= 0 || literal.indexOf('e') >= 0 || literal.indexOf('E') >= 0; + if (fractional) { + return Float.valueOf(literal); + } + try { + return Long.valueOf(literal); + } catch (NumberFormatException overflowsLong) { + // Integral magnitude beyond long range: keep it exact as a BigInteger. + return new BigInteger(literal); + } + } + } + + @Override + public List getTraces() { + return this.traces; + } +} diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/SpanJson.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/SpanJson.java new file mode 100644 index 00000000000..d48bf7cb891 --- /dev/null +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/SpanJson.java @@ -0,0 +1,143 @@ +package datadog.trace.test.agent.decoder.json.raw; + +import static java.util.Collections.emptyMap; +import static java.util.Collections.unmodifiableMap; + +import com.squareup.moshi.Json; +import datadog.trace.test.agent.decoder.DecodedSpan; +import java.util.Map; + +/** + * SpanJson decodes spans from the JSON trace format the dd-apm-test-agent exposes (e.g. from its + * {@code /test/traces} endpoint), which serializes spans in the standard v0.4 shape. Field names + * mirror that wire shape: service/name/resource/type, trace_id/span_id/parent_id, + * start/duration/error, meta, metrics, and meta_struct. + */ +public final class SpanJson implements DecodedSpan { + String service; + String name; + String resource; + String type; + + // IDs are unsigned 64-bit; read as decimal strings and parsed with Long.parseUnsignedLong, since + // Moshi's long adapter rejects values above Long.MAX_VALUE (the agent emits them as JSON numbers, + // which Moshi coerces to their string form). + @Json(name = "trace_id") + String traceId; + + @Json(name = "span_id") + String spanId; + + @Json(name = "parent_id") + String parentId; + + // start and duration are required v0.4 fields; boxed so a missing value decodes to null (and is + // rejected by MessageJson) instead of a silent 0. error is optional per the agent's Span schema + // (absent => 0, i.e. no error), so it stays a primitive. + Long start; + Long duration; + int error; + Map meta; + + @Json(name = "meta_struct") + Map metaStruct; + + Map metrics; + + @Override + public String getService() { + return this.service; + } + + @Override + public String getName() { + return this.name; + } + + @Override + public String getResource() { + return this.resource; + } + + @Override + public long getTraceId() { + return this.traceId == null ? 0L : Long.parseUnsignedLong(this.traceId); + } + + @Override + public long getSpanId() { + return this.spanId == null ? 0L : Long.parseUnsignedLong(this.spanId); + } + + @Override + public long getParentId() { + return this.parentId == null ? 0L : Long.parseUnsignedLong(this.parentId); + } + + @Override + public long getStart() { + return this.start == null ? 0L : this.start; + } + + @Override + public long getDuration() { + return this.duration == null ? 0L : this.duration; + } + + @Override + public int getError() { + return this.error; + } + + @Override + public Map getMeta() { + return this.meta == null ? emptyMap() : unmodifiableMap(this.meta); + } + + @Override + public Map getMetaStruct() { + return this.metaStruct == null ? emptyMap() : unmodifiableMap(this.metaStruct); + } + + @Override + public Map getMetrics() { + return this.metrics == null ? emptyMap() : unmodifiableMap(this.metrics); + } + + @Override + public String getType() { + return this.type; + } + + @Override + public String toString() { + return "SpanJson{" + + "service='" + + this.service + + "', name='" + + this.name + + "', resource='" + + this.resource + + "', type='" + + this.type + + "', traceId=" + + this.traceId + + ", spanId=" + + this.spanId + + ", parentId=" + + this.parentId + + ", start=" + + this.start + + ", duration=" + + this.duration + + ", error=" + + this.error + + ", meta=" + + this.meta + + ", metaStruct=" + + this.metaStruct + + ", metrics=" + + this.metrics + + '}'; + } +} diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/TraceJson.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/TraceJson.java new file mode 100644 index 00000000000..feaa991632d --- /dev/null +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/TraceJson.java @@ -0,0 +1,26 @@ +package datadog.trace.test.agent.decoder.json.raw; + +import static java.util.Collections.unmodifiableList; + +import datadog.trace.test.agent.decoder.DecodedSpan; +import datadog.trace.test.agent.decoder.DecodedTrace; +import java.util.List; + +/** TraceJson is a single trace (an ordered list of {@link SpanJson}) from the JSON trace format. */ +public final class TraceJson implements DecodedTrace { + private final List spans; + + TraceJson(List spans) { + this.spans = unmodifiableList(spans); + } + + @Override + public List getSpans() { + return this.spans; + } + + @Override + public String toString() { + return this.spans.toString(); + } +} diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v04/raw/SpanV04.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v04/raw/SpanV04.java index bae18215d3d..9ee2e8a53e0 100644 --- a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v04/raw/SpanV04.java +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v04/raw/SpanV04.java @@ -1,9 +1,11 @@ package datadog.trace.test.agent.decoder.v04.raw; +import static java.util.Collections.emptyMap; +import static java.util.Collections.unmodifiableMap; + import datadog.trace.test.agent.decoder.DecodedSpan; import java.io.IOException; import java.util.ArrayList; -import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -265,9 +267,9 @@ public SpanV04( this.start = start; this.duration = duration; this.error = error; - this.meta = Collections.unmodifiableMap(meta); - this.metaStruct = metaStruct == null ? null : Collections.unmodifiableMap(metaStruct); - this.metrics = Collections.unmodifiableMap(metrics); + this.meta = unmodifiableMap(meta); + this.metaStruct = metaStruct == null ? emptyMap() : unmodifiableMap(metaStruct); + this.metrics = unmodifiableMap(metrics); this.type = type; } diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v05/raw/SpanV05.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v05/raw/SpanV05.java index 3ceafa45ff5..6ac6469316f 100644 --- a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v05/raw/SpanV05.java +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v05/raw/SpanV05.java @@ -1,5 +1,7 @@ package datadog.trace.test.agent.decoder.v05.raw; +import static java.util.Collections.emptyMap; + import datadog.trace.test.agent.decoder.DecodedSpan; import java.io.IOException; import java.util.Collections; @@ -190,7 +192,7 @@ public Map getMeta() { public Map getMetaStruct() { // XXX: meta_struct is not supported in v0.5. - return null; + return emptyMap(); } public Map getMetrics() { diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v1/raw/SpanV1.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v1/raw/SpanV1.java index 676a450df55..6b0cbaa3bc7 100644 --- a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v1/raw/SpanV1.java +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v1/raw/SpanV1.java @@ -1,9 +1,11 @@ package datadog.trace.test.agent.decoder.v1.raw; +import static java.util.Collections.emptyMap; +import static java.util.Collections.unmodifiableMap; + import datadog.trace.test.agent.decoder.DecodedSpan; import java.io.IOException; import java.util.ArrayList; -import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -492,9 +494,9 @@ public SpanV1( this.start = start; this.duration = duration; this.error = error; - this.meta = Collections.unmodifiableMap(meta); - this.metaStruct = metaStruct == null ? null : Collections.unmodifiableMap(metaStruct); - this.metrics = Collections.unmodifiableMap(metrics); + this.meta = unmodifiableMap(meta); + this.metaStruct = metaStruct == null ? emptyMap() : unmodifiableMap(metaStruct); + this.metrics = unmodifiableMap(metrics); this.type = type; } diff --git a/utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/JsonDecoderTest.java b/utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/JsonDecoderTest.java new file mode 100644 index 00000000000..78d60bf8e0f --- /dev/null +++ b/utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/JsonDecoderTest.java @@ -0,0 +1,285 @@ +package datadog.trace.test.agent.decoder; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.math.BigInteger; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link Decoder#decodeJson(String)}: the dd-apm-test-agent JSON trace body (an array of + * traces, each an array of v0.4-shaped spans) must decode into the same {@link DecodedTrace}/{@link + * DecodedSpan} model the msgpack decoders produce. + */ +class JsonDecoderTest { + + // A representative JSON trace body (an array of traces, each an array of v0.4-shaped spans), kept + // as a resource file rather than an inline literal for readability (folding, syntax + // highlighting). + private static final String TWO_TRACES = loadJson("/two-traces.json"); + + @Test + void decodesTraceAndSpanStructure() { + List traces = Decoder.decodeJson(TWO_TRACES).getTraces(); + + assertEquals(2, traces.size(), "trace count"); + assertEquals(2, traces.get(0).getSpans().size(), "spans in first trace"); + assertEquals(1, traces.get(1).getSpans().size(), "spans in second trace"); + + DecodedSpan root = traces.get(0).getSpans().get(0); + assertEquals("my-service", root.getService()); + assertEquals("servlet.request", root.getName()); + assertEquals("GET /greeting", root.getResource()); + assertEquals("web", root.getType()); + assertEquals(1234567890L, root.getTraceId()); + assertEquals(111L, root.getSpanId()); + assertEquals(0L, root.getParentId()); + assertEquals(1600000000000000000L, root.getStart()); + assertEquals(500000L, root.getDuration()); + assertEquals(0, root.getError()); + } + + @Test + void mapsMetaAsStringsAndMetricsAsNumbers() { + List spans = Decoder.decodeJson(TWO_TRACES).getTraces().get(0).getSpans(); + + Map meta = spans.get(0).getMeta(); + assertEquals("GET", meta.get("http.method")); + assertEquals("200", meta.get("http.status_code")); + + Map metrics = spans.get(0).getMetrics(); + // Integral metrics decode to Integer and fractional metrics to Double, matching the msgpack + // decoders (SpanV04.unpackNumber) so an assertion behaves identically across both backends. + assertEquals(1, metrics.get("_dd.top_level")); + assertEquals(0.75, metrics.get("_dd.agent_psr")); + + // A serialized error span carries error != 0; the parent link is the enclosing root span id. + DecodedSpan child = spans.get(1); + assertEquals(1, child.getError()); + assertEquals(111L, child.getParentId()); + assertEquals("postgres", child.getMeta().get("db.type")); + } + + @Test + void metaStructEmptyWhenAbsentAndAMapWhenPresent() { + // Absent meta_struct decodes to an empty map, matching the msgpack decoders (SpanV04). + DecodedSpan withoutMetaStruct = + Decoder.decodeJson(TWO_TRACES).getTraces().get(0).getSpans().get(0); + assertTrue(withoutMetaStruct.getMetaStruct().isEmpty()); + + String withMetaStruct = loadJson("/with-meta-struct.json"); + Map metaStruct = + Decoder.decodeJson(withMetaStruct).getTraces().get(0).getSpans().get(0).getMetaStruct(); + assertTrue(metaStruct.containsKey("appsec")); + } + + @Test + void emptyAndNullBodiesYieldNoTraces() { + assertTrue(Decoder.decodeJson("[]").getTraces().isEmpty(), "empty array => no traces"); + // Moshi parses the JSON literal null as a null document; decode tolerates it. + assertTrue(Decoder.decodeJson("null").getTraces().isEmpty(), "null body => no traces"); + + List oneEmptyTrace = Decoder.decodeJson("[[]]").getTraces(); + assertEquals(1, oneEmptyTrace.size()); + assertTrue(oneEmptyTrace.get(0).getSpans().isEmpty(), "empty trace => no spans"); + } + + @Test + void decodesUnsignedIds() { + // Trace/span IDs are unsigned 64-bit; the agent emits them as JSON numbers that can exceed + // Long.MAX_VALUE. They must be parsed unsigned and kept as the signed bit pattern. + String maxUnsigned = Long.toUnsignedString(-1L); // 18446744073709551615 == 2^64 - 1 + String json = + "[[{" + + "\"service\": \"s\", \"name\": \"n\", \"resource\": \"r\", \"type\": \"web\"," + + "\"trace_id\": " + + maxUnsigned + + ", \"span_id\": " + + maxUnsigned + + ", \"parent_id\": 0, \"start\": 0, \"duration\": 0, \"error\": 0," + + "\"meta\": {}, \"metrics\": {}" + + "}]]"; + DecodedSpan span = Decoder.decodeJson(json).getTraces().get(0).getSpans().get(0); + assertEquals(-1L, span.getTraceId(), "unsigned 2^64-1 kept as its signed bit pattern"); + assertEquals(-1L, span.getSpanId()); + assertEquals(0L, span.getParentId()); + } + + @Test + void preservesIntegralMetricTypesAndPrecision() { + // Integral metrics that fit an int stay Integer; larger integrals stay Long (exact, not rounded + // through Double); integrals beyond long range stay BigInteger (still exact, not Double); + // fractional metrics stay Double — matching SpanV04/SpanV05.unpackNumber so a metric assertion + // behaves identically whether the span came from the msgpack or JSON backend. + long aboveDoublePrecision = (1L << 53) + 1; // 9007199254740993, not representable as a double + BigInteger beyondLong = BigInteger.valueOf(Long.MAX_VALUE).add(BigInteger.ONE); // 2^63 + String json = + "[[{" + + "\"service\": \"s\", \"name\": \"n\", \"resource\": \"r\"," + + "\"trace_id\": 1, \"span_id\": 1, \"parent_id\": 0, \"start\": 0, \"duration\": 0," + + "\"error\": 0, \"meta\": {}," + + "\"metrics\": {\"small\": 1, \"big\": " + + aboveDoublePrecision + + ", \"huge\": " + + beyondLong + + ", \"ratio\": 0.5}" + + "}]]"; + Map metrics = + Decoder.decodeJson(json).getTraces().get(0).getSpans().get(0).getMetrics(); + + assertEquals(1, metrics.get("small"), "int-range integral stays Integer"); + assertEquals(aboveDoublePrecision, metrics.get("big"), "large integral stays Long, exact"); + assertEquals(beyondLong, metrics.get("huge"), "beyond-long integral stays BigInteger, exact"); + assertEquals(0.5, metrics.get("ratio"), "fractional stays Double"); + } + + @Test + void rejectsSpansMissingRequiredV04Fields() { + // The agent's Span schema marks name/trace_id/span_id/start/duration as required; a truncated + // or + // schema-mismatched response omitting any of them must fail loudly rather than decode into a + // span whose fields silently default to 0/null. + assertThrows( + IllegalStateException.class, + () -> + Decoder.decodeJson(oneSpan("\"trace_id\":1,\"span_id\":1,\"start\":0,\"duration\":0")), + "missing name"); + assertThrows( + IllegalStateException.class, + () -> + Decoder.decodeJson(oneSpan("\"name\":\"n\",\"span_id\":1,\"start\":0,\"duration\":0")), + "missing trace_id"); + assertThrows( + IllegalStateException.class, + () -> + Decoder.decodeJson(oneSpan("\"name\":\"n\",\"trace_id\":1,\"start\":0,\"duration\":0")), + "missing span_id"); + assertThrows( + IllegalStateException.class, + () -> + Decoder.decodeJson( + oneSpan("\"name\":\"n\",\"trace_id\":1,\"span_id\":1,\"duration\":0")), + "missing start"); + assertThrows( + IllegalStateException.class, + () -> + Decoder.decodeJson(oneSpan("\"name\":\"n\",\"trace_id\":1,\"span_id\":1,\"start\":0")), + "missing duration"); + + // error and parent_id are optional per the agent schema: a span omitting both still decodes, + // with error defaulting to 0 (no error) and parent_id to 0 (root). + DecodedSpan span = + Decoder.decodeJson( + oneSpan("\"name\":\"n\",\"trace_id\":1,\"span_id\":1,\"start\":0,\"duration\":0")) + .getTraces() + .get(0) + .getSpans() + .get(0); + assertEquals(0, span.getError(), "absent error => 0"); + assertEquals(0L, span.getParentId(), "absent parent_id => 0 (root)"); + } + + @Test + void rejectsNullTraceEntry() { + // A null trace element ([null]) is corruption, not an empty trace; fail rather than silently + // decode it into an empty trace that still inflates the trace count. + assertThrows(IllegalStateException.class, () -> Decoder.decodeJson("[null]")); + } + + @Test + void decodedTraceListIsUnmodifiable() { + // Matches the msgpack DecodedMessage implementations, which all expose an unmodifiable list. + List traces = Decoder.decodeJson(TWO_TRACES).getTraces(); + assertThrows(UnsupportedOperationException.class, traces::clear); + } + + /** Wraps a single span's JSON fields into a one-trace, one-span payload. */ + private static String oneSpan(String fields) { + return "[[{" + fields + "}]]"; + } + + /** Reads a JSON test fixture from the classpath (kept as a resource for readability). */ + private static String loadJson(String resourceName) { + try (InputStream in = JsonDecoderTest.class.getResourceAsStream(resourceName)) { + if (in == null) { + throw new IllegalStateException("Missing test resource: " + resourceName); + } + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int read; + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + return out.toString("UTF-8"); + } catch (IOException e) { + throw new UncheckedIOException("Failed to read test resource: " + resourceName, e); + } + } + + @Test + void preservesMetaStructNumericTypesAcrossNesting() { + // meta_struct numeric leaves must decode to the same types the msgpack decoder produces + // (SpanV04.convertValueToObject): integers -> Long, floating-point -> Float, recursively + // through + // nested objects and arrays, with strings/booleans/null preserved. Otherwise an exact + // metaStruct(...) assertion or a leaf cast would behave differently across the two backends. + long aboveDoublePrecision = (1L << 53) + 1; // 9007199254740993, not representable as a double + BigInteger beyondLong = BigInteger.valueOf(Long.MAX_VALUE).add(BigInteger.ONE); // 2^63 + String json = + "[[{" + + "\"service\": \"s\", \"name\": \"n\", \"resource\": \"r\"," + + "\"trace_id\": 1, \"span_id\": 1, \"parent_id\": 0, \"start\": 0, \"duration\": 0," + + "\"error\": 0, \"meta\": {}, \"metrics\": {}," + + "\"meta_struct\": {\"appsec\": {" + + " \"count\": 3," + + " \"rate\": 0.5," + + " \"whole\": 3.0," + + " \"nested\": {\"big\": " + + aboveDoublePrecision + + ", \"huge\": " + + beyondLong + + "}," + + " \"list\": [1, 2.5, \"x\", true, null]" + + "}}" + + "}]]"; + Map metaStruct = + Decoder.decodeJson(json).getTraces().get(0).getSpans().get(0).getMetaStruct(); + + @SuppressWarnings("unchecked") + Map appsec = (Map) metaStruct.get("appsec"); + assertEquals(3L, appsec.get("count"), "integer leaf -> Long"); + assertEquals(0.5F, appsec.get("rate"), "float leaf -> Float"); + // A whole-number float: the live agent emits it as "3.0" (json.dumps keeps the decimal point), + // so it must box to Float like the msgpack FLOAT leaf, not Long. Verified via a real + // round-trip. + assertEquals(3.0F, appsec.get("whole"), "whole-number float keeps .0 -> Float"); + + @SuppressWarnings("unchecked") + Map nested = (Map) appsec.get("nested"); + assertEquals(aboveDoublePrecision, nested.get("big"), "large integer stays Long, exact"); + assertEquals(beyondLong, nested.get("huge"), "beyond-long integer stays BigInteger, exact"); + + @SuppressWarnings("unchecked") + List list = (List) appsec.get("list"); + assertEquals(1L, list.get(0), "integer in array -> Long"); + assertEquals(2.5F, list.get(1), "float in array -> Float"); + assertEquals("x", list.get(2), "string in array preserved"); + assertEquals(Boolean.TRUE, list.get(3), "boolean in array preserved"); + assertNull(list.get(4), "null leaf preserved"); + } + + @Test + void malformedJsonThrows() { + IllegalStateException e = + assertThrows(IllegalStateException.class, () -> Decoder.decodeJson("{ not valid json")); + assertTrue(e.getMessage().contains("{ not valid json"), "message should include the body"); + } +} diff --git a/utils/test-agent-utils/decoder/src/test/resources/two-traces.json b/utils/test-agent-utils/decoder/src/test/resources/two-traces.json new file mode 100644 index 00000000000..65c93961964 --- /dev/null +++ b/utils/test-agent-utils/decoder/src/test/resources/two-traces.json @@ -0,0 +1,48 @@ +[ + [ + { + "service": "my-service", + "name": "servlet.request", + "resource": "GET /greeting", + "type": "web", + "trace_id": 1234567890, + "span_id": 111, + "parent_id": 0, + "start": 1600000000000000000, + "duration": 500000, + "error": 0, + "meta": {"http.method": "GET", "http.status_code": "200"}, + "metrics": {"_dd.top_level": 1, "_dd.agent_psr": 0.75} + }, + { + "service": "my-service", + "name": "repository.query", + "resource": "SELECT users", + "type": "sql", + "trace_id": 1234567890, + "span_id": 222, + "parent_id": 111, + "start": 1600000000000100000, + "duration": 200000, + "error": 1, + "meta": {"db.type": "postgres"}, + "metrics": {} + } + ], + [ + { + "service": "batch", + "name": "scheduled.job", + "resource": "nightly", + "type": "custom", + "trace_id": 42, + "span_id": 7, + "parent_id": 0, + "start": 1, + "duration": 1, + "error": 0, + "meta": {}, + "metrics": {} + } + ] +] diff --git a/utils/test-agent-utils/decoder/src/test/resources/with-meta-struct.json b/utils/test-agent-utils/decoder/src/test/resources/with-meta-struct.json new file mode 100644 index 00000000000..a450cfad6fa --- /dev/null +++ b/utils/test-agent-utils/decoder/src/test/resources/with-meta-struct.json @@ -0,0 +1,19 @@ +[ + [ + { + "service": "s", + "name": "n", + "resource": "r", + "type": "web", + "trace_id": 1, + "span_id": 1, + "parent_id": 0, + "start": 0, + "duration": 0, + "error": 0, + "meta": {}, + "metrics": {}, + "meta_struct": {"appsec": {"triggers": 3}} + } + ] +]