From 98b712fa57f786746c66cff654faa286a512d090 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 | 189 ++++++++++++ .../test/agent/decoder/json/raw/SpanJson.java | 143 +++++++++ .../agent/decoder/json/raw/TraceJson.java | 26 ++ .../test/agent/decoder/JsonDecoderTest.java | 275 ++++++++++++++++++ .../src/test/resources/two-traces.json | 48 +++ .../src/test/resources/with-meta-struct.json | 19 ++ 9 files changed, 711 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 create mode 100644 utils/test-agent-utils/decoder/src/test/resources/two-traces.json create mode 100644 utils/test-agent-utils/decoder/src/test/resources/with-meta-struct.json 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..9869210ebea --- /dev/null +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/MessageJson.java @@ -0,0 +1,189 @@ +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.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}), fractional values become {@code Double}. Reading the literal rather than + * coercing through {@code double} also keeps integral values above 2^53 exact. + */ + 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: fall through to Double. + } + } + } + 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), + *
  • 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 token and are + * therefore decoded as whatever the agent serializes them to (typically a string); they are the + * one leaf type the two backends cannot represent identically. + */ + 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: degrade to Double rather than fail. + return Double.valueOf(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/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..3e30cb7fdb4 --- /dev/null +++ b/utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/JsonDecoderTest.java @@ -0,0 +1,275 @@ +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.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); 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 + 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 + + ", \"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(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 + 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 + + "}," + + " \"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"); + + @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}} + } + ] +]