Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions utils/test-agent-utils/decoder/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
4 changes: 3 additions & 1 deletion utils/test-agent-utils/decoder/gradle.lockfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Comment on lines +24 to +27

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: Would be nice to have a bit more info how this class used with dd-apm-test-agent ?

*/
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<List<List<SpanJson>>> ADAPTER =
new Moshi.Builder()
.add(new MetricNumberAdapter())
.add(new MetaStructObjectAdapter())
.build()
.adapter(LIST_OF_TRACES);

private final List<DecodedTrace> traces;

private MessageJson(List<DecodedTrace> traces) {
this.traces = unmodifiableList(traces);
}

/** Decodes a JSON trace payload into a {@link MessageJson}. */
public static MessageJson fromJson(String json) {
List<List<SpanJson>> rawTraces;
try {
rawTraces = ADAPTER.fromJson(json);
} catch (IOException | JsonDataException e) {
throw new IllegalStateException("Failed to parse JSON traces: " + json, e);
}
List<DecodedTrace> traces = new ArrayList<>();
if (rawTraces != null) {
for (List<SpanJson> spans : rawTraces) {
if (spans == null) {
throw new IllegalStateException("Malformed JSON trace with a null trace: " + json);
}
List<DecodedSpan> 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:
*
* <ul>
* <li>integers become {@code Long} (reading the literal keeps values above 2^53 exact),
* <li>floating-point numbers become {@code Float},
* <li>objects become {@code Map<String, Object>} and arrays {@code List<Object>}, recursively,
* <li>strings, booleans, and JSON {@code null} are preserved as {@code String}/{@code
* Boolean}/{@code null}.
* </ul>
*
* <p>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<String, Object> object = new LinkedHashMap<>();
reader.beginObject();
while (reader.hasNext()) {
object.put(reader.nextName(), fromJson(reader));
}
reader.endObject();
return object;
case BEGIN_ARRAY:
List<Object> 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<DecodedTrace> getTraces() {
return this.traces;
Comment thread
PerfectSlayer marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
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<String, String> meta;

@Json(name = "meta_struct")
Map<String, Object> metaStruct;
Comment on lines +42 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Decode binary meta_struct entries before exposing them

When a trace contains meta_struct, each v0.4 entry is a MessagePack-encoded binary payload (see TraceMapperV0_4.MetaStructWriter), which /test/traces represents as a base64 JSON string. Deserializing directly into Map<String, Object> therefore exposes strings instead of the structured objects returned by the existing v0.4 decoder, so AppSec/LLM smoke tests that inspect or cast these values will fail or evaluate the wrong data. Decode each base64 value and unpack its MessagePack payload; the current synthetic test should also use the wire representation rather than an already-decoded object.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does not apply here. From Claude:

Thanks — this is the right instinct in general (in the raw msgpack v0.4/v1 wire formats, meta_struct values are binary MessagePack blobs, which is exactly why SpanV04/SpanV1 decode them). But it doesn't apply to the JSON this decoder consumes: the dd-apm-test-agent decodes meta_struct server-side before serving /test/traces and /test/session/traces, so the JSON already carries nested objects, not base64.

From ddapm_test_agent/trace.py at the pinned v1.44.0:
meta_struct: NotRequired[Dict[str, Dict[str, Any]]] # decoded dicts, not strings

def _parse_meta_struct(value):
return {key: msgpack.unpackb(val_bytes) for key, val_bytes in value.items()}
_parse_meta_struct is applied via _flexible_decode_meta_struct(payload) inside decode_v04(...), and handle_session_traces / handle_test_traces simply web.json_response(traces) those already-decoded traces. Source: https://github.com/DataDog/dd-apm-test-agent/blob/v1.44.0/ddapm_test_agent/trace.py#L363-L367

So typing meta_struct as base64 Map<String,String> and re-decoding would actually break against the real agent (the value is a nested JSON object, not a string). Keeping Map<String,Object> and letting the value deserialize directly is correct here. Not making this change; verified against the agent source (and the existing metaStructEmptyWhenAbsentAndAMapWhenPresent fixture uses the real nested shape).


Map<String, Number> 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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Decode base64 meta_struct payloads

Smoke tests inspecting AppSec or other structured span metadata will see the wrong type and can fail or silently miss the metadata they are intended to validate.

Assertion details
  • Input: A /test/traces JSON response containing v0.4 meta_struct, for example meta_struct:{"appsec":"<base64 of JSON {"triggers":3}>"}. The test-agent wire representation uses binary meta_struct values, and JSON encoding represents those byte arrays as base64 strings.
  • Expected: getMetaStruct() returns the decoded structured value, matching SpanV04 and the DecodedSpan contract, so callers can inspect nested maps and lists.
  • Actual: SpanJson declares metaStruct as Map<String,Object> and returns it directly. Moshi therefore exposes the base64 text as a String instead of decoding the bytes and parsing the JSON payload.

Was this helpful? React 👍 or 👎
🤖 Datadog Autotest · What is Autotest? · @DataDog review to ask questions · Any feedback? Reach out in #autotest

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Override
public int getError() {
return this.error;
}

@Override
public Map<String, String> getMeta() {
return this.meta == null ? emptyMap() : unmodifiableMap(this.meta);
}

@Override
public Map<String, Object> getMetaStruct() {
return this.metaStruct == null ? emptyMap() : unmodifiableMap(this.metaStruct);
}

@Override
public Map<String, Number> 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
+ '\''

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: not sure, but probably "'" would be better?
or even String.format() if this toString is not expected to be called often?

+ ", 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
+ '}';
}
}
Loading