From d14912e8355aef96ea7f5cc4ebcc6cff0fefa768 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Tue, 28 Jul 2026 11:52:48 +0200 Subject: [PATCH] feat(test-agent): Add APM test agent trace decoding --- .../test-agent-utils/decoder/build.gradle.kts | 2 + .../test-agent-utils/decoder/gradle.lockfile | 4 +- .../trace/test/agent/decoder/Decoder.java | 6 + .../agent/decoder/json/raw/MessageJson.java | 61 +++++++ .../test/agent/decoder/json/raw/SpanJson.java | 145 +++++++++++++++ .../agent/decoder/json/raw/TraceJson.java | 26 +++ .../test/agent/decoder/JsonDecoderTest.java | 167 ++++++++++++++++++ 7 files changed, 410 insertions(+), 1 deletion(-) create mode 100644 utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/MessageJson.java create mode 100644 utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/SpanJson.java create mode 100644 utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/TraceJson.java create mode 100644 utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/JsonDecoderTest.java 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..10064a8ac38 --- /dev/null +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/MessageJson.java @@ -0,0 +1,61 @@ +package datadog.trace.test.agent.decoder.json.raw; + +import static java.util.Collections.emptyList; + +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.JsonDataException; +import com.squareup.moshi.Moshi; +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.util.ArrayList; +import java.util.List; + +/** + * 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().build().adapter(LIST_OF_TRACES); + + private final List traces; + + private MessageJson(List traces) { + this.traces = traces; + } + + /** Decodes a JSON trace payload into a {@link MessageJson}. */ + public static MessageJson fromJson(String json) { + List> rawTraces; + try { + // IOException covers malformed JSON (JsonEncodingException); JsonDataException (unchecked) + // covers a well-formed body of the wrong shape (e.g. an object where a trace array is + // expected). Wrap both so callers always get the offending body for diagnosis. + 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) { + List decodedSpans = spans == null ? emptyList() : new ArrayList<>(spans); + traces.add(new TraceJson(decodedSpans)); + } + } + return new MessageJson(traces); + } + + @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..5a22e72221a --- /dev/null +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/SpanJson.java @@ -0,0 +1,145 @@ +package datadog.trace.test.agent.decoder.json.raw; + +import static java.util.Collections.emptyMap; + +import com.squareup.moshi.Json; +import datadog.trace.test.agent.decoder.DecodedSpan; +import java.util.HashMap; +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; + + 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; + } + + @Override + public long getDuration() { + return this.duration; + } + + @Override + public int getError() { + return this.error; + } + + @Override + public Map getMeta() { + return this.meta == null ? emptyMap() : this.meta; + } + + @Override + public Map getMetaStruct() { + return this.metaStruct == null ? emptyMap() : this.metaStruct; + } + + @Override + public Map getMetrics() { + // Moshi deserializes JSON numbers as Double; expose them as the interface's Number type. + return this.metrics == null ? emptyMap() : new HashMap<>(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/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..f35c677a4d0 --- /dev/null +++ b/utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/JsonDecoderTest.java @@ -0,0 +1,167 @@ +package datadog.trace.test.agent.decoder; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +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. + private static final String TWO_TRACES = + "[" + + " [" + + " {" + + " \"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\": {}" + + " }" + + " ]" + + "]"; + + @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(); + // Moshi decodes every JSON number in the metrics map as a Double, matching the Number model. + assertEquals(1.0, metrics.get("_dd.top_level").doubleValue()); + assertEquals(0.75, metrics.get("_dd.agent_psr").doubleValue()); + + // 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 = + "[[{" + + "\"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}}" + + "}]]"; + 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 malformedJsonThrows() { + IllegalStateException e = + assertThrows(IllegalStateException.class, () -> Decoder.decodeJson("{ not valid json")); + assertTrue(e.getMessage().contains("{ not valid json"), "message should include the body"); + } +}