Skip to content

Commit db219d3

Browse files
Add APM test agent trace decoding (#12089)
feat(ci): Add test agent sidecar to smoke tests feat(core): Add support for test session token header feat(testing): Avoid null collection feat(test-agent): Add APM test agent trace decoding Co-authored-by: bruce.bujon <bruce.bujon@datadoghq.com>
1 parent a86b3de commit db219d3

9 files changed

Lines changed: 726 additions & 1 deletion

File tree

‎utils/test-agent-utils/decoder/build.gradle.kts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ extra["excludedClassesCoverage"] = listOf(
88
"datadog.trace.test.agent.decoder.v04.raw.*",
99
"datadog.trace.test.agent.decoder.v05.raw.*",
1010
"datadog.trace.test.agent.decoder.v1.raw.*",
11+
"datadog.trace.test.agent.decoder.json.raw.*",
1112
)
1213

1314
dependencies {
1415
implementation(group = "org.msgpack", name = "msgpack-core", version = "0.8.24")
16+
implementation(libs.moshi)
1517

1618
testImplementation(libs.bundles.junit5)
1719
testImplementation(project(":utils:test-utils"))

‎utils/test-agent-utils/decoder/gradle.lockfile‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
1212
com.google.code.findbugs:jsr305:3.0.2=compileClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
1313
com.google.code.gson:gson:2.13.2=spotbugs
1414
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
15+
com.squareup.moshi:moshi:1.11.0=compileClasspath,testCompileClasspath,testRuntimeClasspath
16+
com.squareup.okio:okio:1.17.5=compileClasspath,testCompileClasspath,testRuntimeClasspath
1517
com.thoughtworks.qdox:qdox:1.12.1=codenarc
1618
commons-fileupload:commons-fileupload:1.5=testCompileClasspath,testRuntimeClasspath
1719
commons-io:commons-io:2.11.0=testCompileClasspath,testRuntimeClasspath
@@ -29,7 +31,7 @@ org.apache.commons:commons-lang3:3.19.0=spotbugs
2931
org.apache.commons:commons-text:1.14.0=spotbugs
3032
org.apache.logging.log4j:log4j-api:2.25.2=spotbugs
3133
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
32-
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
34+
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath,testRuntimeClasspath
3335
org.codehaus.groovy:groovy-ant:3.0.23=codenarc
3436
org.codehaus.groovy:groovy-docgenerator:3.0.23=codenarc
3537
org.codehaus.groovy:groovy-groovydoc:3.0.23=codenarc

‎utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/Decoder.java‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package datadog.trace.test.agent.decoder;
22

3+
import datadog.trace.test.agent.decoder.json.raw.MessageJson;
34
import datadog.trace.test.agent.decoder.v04.raw.MessageV04;
45
import datadog.trace.test.agent.decoder.v05.raw.MessageV05;
56
import datadog.trace.test.agent.decoder.v1.raw.MessageV1;
@@ -12,6 +13,11 @@ public static DecodedMessage decodeV1(byte[] buffer) {
1213
return MessageV1.unpack(buffer);
1314
}
1415

16+
/** Decodes the JSON trace format exposed by the dd-apm-test-agent. */
17+
public static DecodedMessage decodeJson(String json) {
18+
return MessageJson.fromJson(json);
19+
}
20+
1521
public static DecodedMessage decodeV05(byte[] buffer) {
1622
return MessageV05.unpack(buffer);
1723
}
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
package datadog.trace.test.agent.decoder.json.raw;
2+
3+
import static java.util.Collections.unmodifiableList;
4+
5+
import com.squareup.moshi.FromJson;
6+
import com.squareup.moshi.JsonAdapter;
7+
import com.squareup.moshi.JsonDataException;
8+
import com.squareup.moshi.JsonReader;
9+
import com.squareup.moshi.JsonWriter;
10+
import com.squareup.moshi.Moshi;
11+
import com.squareup.moshi.ToJson;
12+
import com.squareup.moshi.Types;
13+
import datadog.trace.test.agent.decoder.DecodedMessage;
14+
import datadog.trace.test.agent.decoder.DecodedSpan;
15+
import datadog.trace.test.agent.decoder.DecodedTrace;
16+
import java.io.IOException;
17+
import java.lang.reflect.Type;
18+
import java.math.BigInteger;
19+
import java.util.ArrayList;
20+
import java.util.LinkedHashMap;
21+
import java.util.List;
22+
import java.util.Map;
23+
24+
/**
25+
* MessageJson decodes a JSON trace payload — a JSON array of traces, each a JSON array of spans in
26+
* the v0.4 shape, as exposed by the dd-apm-test-agent — into the shared {@link DecodedMessage}
27+
* model. Unlike the msgpack formats there is no message envelope, so the payload maps directly to
28+
* the list of traces.
29+
*/
30+
public final class MessageJson implements DecodedMessage {
31+
private static final Type LIST_OF_TRACES =
32+
Types.newParameterizedType(
33+
List.class, Types.newParameterizedType(List.class, SpanJson.class));
34+
private static final JsonAdapter<List<List<SpanJson>>> ADAPTER =
35+
new Moshi.Builder()
36+
.add(new MetricNumberAdapter())
37+
.add(new MetaStructObjectAdapter())
38+
.build()
39+
.adapter(LIST_OF_TRACES);
40+
41+
private final List<DecodedTrace> traces;
42+
43+
private MessageJson(List<DecodedTrace> traces) {
44+
this.traces = unmodifiableList(traces);
45+
}
46+
47+
/** Decodes a JSON trace payload into a {@link MessageJson}. */
48+
public static MessageJson fromJson(String json) {
49+
List<List<SpanJson>> rawTraces;
50+
try {
51+
rawTraces = ADAPTER.fromJson(json);
52+
} catch (IOException | JsonDataException e) {
53+
throw new IllegalStateException("Failed to parse JSON traces: " + json, e);
54+
}
55+
List<DecodedTrace> traces = new ArrayList<>();
56+
if (rawTraces != null) {
57+
for (List<SpanJson> spans : rawTraces) {
58+
if (spans == null) {
59+
throw new IllegalStateException("Malformed JSON trace with a null trace: " + json);
60+
}
61+
List<DecodedSpan> decodedSpans = new ArrayList<>(spans.size());
62+
for (SpanJson span : spans) {
63+
if (span == null) {
64+
throw new IllegalStateException("Malformed JSON trace with a null span: " + json);
65+
}
66+
if (span.name == null
67+
|| span.traceId == null
68+
|| span.spanId == null
69+
|| span.start == null
70+
|| span.duration == null) {
71+
throw new IllegalStateException(
72+
"JSON span missing a required v0.4 field "
73+
+ "(name, trace_id, span_id, start, duration): "
74+
+ span);
75+
}
76+
decodedSpans.add(span);
77+
}
78+
traces.add(new TraceJson(decodedSpans));
79+
}
80+
}
81+
return new MessageJson(traces);
82+
}
83+
84+
/**
85+
* Decodes the numeric values of the {@code metrics} map. Moshi coerces every JSON number to
86+
* {@code Double}; this adapter instead reads the number from its literal form and preserves the
87+
* integral vs. fractional distinction the msgpack decoders produce (see {@code
88+
* SpanV04.unpackNumber}): integral values become {@code Integer} (or {@code Long} when they
89+
* overflow {@code int}, or {@code BigInteger} beyond {@code long} range), fractional values
90+
* become {@code Double}. Reading the literal rather than coercing through {@code double} keeps
91+
* every integral value exact — including those above 2^53 and above {@code Long.MAX_VALUE}.
92+
*/
93+
static final class MetricNumberAdapter {
94+
@FromJson
95+
Number fromJson(JsonReader reader) throws IOException {
96+
String literal = reader.nextString();
97+
boolean fractional =
98+
literal.indexOf('.') >= 0 || literal.indexOf('e') >= 0 || literal.indexOf('E') >= 0;
99+
if (!fractional) {
100+
try {
101+
return Integer.valueOf(literal);
102+
} catch (NumberFormatException overflowsInt) {
103+
try {
104+
return Long.valueOf(literal);
105+
} catch (NumberFormatException overflowsLong) {
106+
// Integral magnitude beyond long range: keep it exact as a BigInteger.
107+
return new BigInteger(literal);
108+
}
109+
}
110+
}
111+
return Double.valueOf(literal);
112+
}
113+
114+
@ToJson
115+
void toJson(JsonWriter writer, Number value) throws IOException {
116+
writer.value(value);
117+
}
118+
}
119+
120+
/**
121+
* Decodes the arbitrarily-nested values of the {@code meta_struct} map. Moshi's generic {@code
122+
* Object} adapter materializes every JSON number as {@code Double}, every object as a map, and
123+
* every array as a list; this adapter instead mirrors the msgpack decoder (see {@code
124+
* SpanV04.convertValueToObject}) so both backends expose the same types for a nested leaf:
125+
*
126+
* <ul>
127+
* <li>integers become {@code Long} (reading the literal keeps values above 2^53 exact, and
128+
* values beyond {@code long} range become {@code BigInteger} rather than a lossy {@code
129+
* Double}),
130+
* <li>floating-point numbers become {@code Float},
131+
* <li>objects become {@code Map<String, Object>} and arrays {@code List<Object>}, recursively,
132+
* <li>strings, booleans, and JSON {@code null} are preserved as {@code String}/{@code
133+
* Boolean}/{@code null}.
134+
* </ul>
135+
*
136+
* <p>Keeping the representations identical means a {@code metaStruct(...)} assertion behaves the
137+
* same whether the span came from the in-process msgpack backend or the dd-apm-test-agent JSON
138+
* backend. Binary leaves ({@code byte[]} on the msgpack side) have no JSON representation — the
139+
* agent fails to serialize them (HTTP 400) so they never reach this decoder; in practice {@code
140+
* meta_struct} carries only JSON-serializable data.
141+
*/
142+
static final class MetaStructObjectAdapter {
143+
@FromJson
144+
Object fromJson(JsonReader reader) throws IOException {
145+
switch (reader.peek()) {
146+
case BEGIN_OBJECT:
147+
Map<String, Object> object = new LinkedHashMap<>();
148+
reader.beginObject();
149+
while (reader.hasNext()) {
150+
object.put(reader.nextName(), fromJson(reader));
151+
}
152+
reader.endObject();
153+
return object;
154+
case BEGIN_ARRAY:
155+
List<Object> array = new ArrayList<>();
156+
reader.beginArray();
157+
while (reader.hasNext()) {
158+
array.add(fromJson(reader));
159+
}
160+
reader.endArray();
161+
return array;
162+
case STRING:
163+
return reader.nextString();
164+
case NUMBER:
165+
return parseNumber(reader.nextString());
166+
case BOOLEAN:
167+
return reader.nextBoolean();
168+
case NULL:
169+
return reader.nextNull();
170+
default:
171+
throw new JsonDataException("Unexpected meta_struct token: " + reader.peek());
172+
}
173+
}
174+
175+
private static Number parseNumber(String literal) {
176+
boolean fractional =
177+
literal.indexOf('.') >= 0 || literal.indexOf('e') >= 0 || literal.indexOf('E') >= 0;
178+
if (fractional) {
179+
return Float.valueOf(literal);
180+
}
181+
try {
182+
return Long.valueOf(literal);
183+
} catch (NumberFormatException overflowsLong) {
184+
// Integral magnitude beyond long range: keep it exact as a BigInteger.
185+
return new BigInteger(literal);
186+
}
187+
}
188+
}
189+
190+
@Override
191+
public List<DecodedTrace> getTraces() {
192+
return this.traces;
193+
}
194+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package datadog.trace.test.agent.decoder.json.raw;
2+
3+
import static java.util.Collections.emptyMap;
4+
import static java.util.Collections.unmodifiableMap;
5+
6+
import com.squareup.moshi.Json;
7+
import datadog.trace.test.agent.decoder.DecodedSpan;
8+
import java.util.Map;
9+
10+
/**
11+
* SpanJson decodes spans from the JSON trace format the dd-apm-test-agent exposes (e.g. from its
12+
* {@code /test/traces} endpoint), which serializes spans in the standard v0.4 shape. Field names
13+
* mirror that wire shape: service/name/resource/type, trace_id/span_id/parent_id,
14+
* start/duration/error, meta, metrics, and meta_struct.
15+
*/
16+
public final class SpanJson implements DecodedSpan {
17+
String service;
18+
String name;
19+
String resource;
20+
String type;
21+
22+
// IDs are unsigned 64-bit; read as decimal strings and parsed with Long.parseUnsignedLong, since
23+
// Moshi's long adapter rejects values above Long.MAX_VALUE (the agent emits them as JSON numbers,
24+
// which Moshi coerces to their string form).
25+
@Json(name = "trace_id")
26+
String traceId;
27+
28+
@Json(name = "span_id")
29+
String spanId;
30+
31+
@Json(name = "parent_id")
32+
String parentId;
33+
34+
// start and duration are required v0.4 fields; boxed so a missing value decodes to null (and is
35+
// rejected by MessageJson) instead of a silent 0. error is optional per the agent's Span schema
36+
// (absent => 0, i.e. no error), so it stays a primitive.
37+
Long start;
38+
Long duration;
39+
int error;
40+
Map<String, String> meta;
41+
42+
@Json(name = "meta_struct")
43+
Map<String, Object> metaStruct;
44+
45+
Map<String, Number> metrics;
46+
47+
@Override
48+
public String getService() {
49+
return this.service;
50+
}
51+
52+
@Override
53+
public String getName() {
54+
return this.name;
55+
}
56+
57+
@Override
58+
public String getResource() {
59+
return this.resource;
60+
}
61+
62+
@Override
63+
public long getTraceId() {
64+
return this.traceId == null ? 0L : Long.parseUnsignedLong(this.traceId);
65+
}
66+
67+
@Override
68+
public long getSpanId() {
69+
return this.spanId == null ? 0L : Long.parseUnsignedLong(this.spanId);
70+
}
71+
72+
@Override
73+
public long getParentId() {
74+
return this.parentId == null ? 0L : Long.parseUnsignedLong(this.parentId);
75+
}
76+
77+
@Override
78+
public long getStart() {
79+
return this.start == null ? 0L : this.start;
80+
}
81+
82+
@Override
83+
public long getDuration() {
84+
return this.duration == null ? 0L : this.duration;
85+
}
86+
87+
@Override
88+
public int getError() {
89+
return this.error;
90+
}
91+
92+
@Override
93+
public Map<String, String> getMeta() {
94+
return this.meta == null ? emptyMap() : unmodifiableMap(this.meta);
95+
}
96+
97+
@Override
98+
public Map<String, Object> getMetaStruct() {
99+
return this.metaStruct == null ? emptyMap() : unmodifiableMap(this.metaStruct);
100+
}
101+
102+
@Override
103+
public Map<String, Number> getMetrics() {
104+
return this.metrics == null ? emptyMap() : unmodifiableMap(this.metrics);
105+
}
106+
107+
@Override
108+
public String getType() {
109+
return this.type;
110+
}
111+
112+
@Override
113+
public String toString() {
114+
return "SpanJson{"
115+
+ "service='"
116+
+ this.service
117+
+ "', name='"
118+
+ this.name
119+
+ "', resource='"
120+
+ this.resource
121+
+ "', type='"
122+
+ this.type
123+
+ "', traceId="
124+
+ this.traceId
125+
+ ", spanId="
126+
+ this.spanId
127+
+ ", parentId="
128+
+ this.parentId
129+
+ ", start="
130+
+ this.start
131+
+ ", duration="
132+
+ this.duration
133+
+ ", error="
134+
+ this.error
135+
+ ", meta="
136+
+ this.meta
137+
+ ", metaStruct="
138+
+ this.metaStruct
139+
+ ", metrics="
140+
+ this.metrics
141+
+ '}';
142+
}
143+
}

0 commit comments

Comments
 (0)