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,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<List<List<SpanJson>>> ADAPTER =
new Moshi.Builder().build().adapter(LIST_OF_TRACES);

private final List<DecodedTrace> traces;

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

/** Decodes a JSON trace payload into a {@link MessageJson}. */
public static MessageJson fromJson(String json) {
List<List<SpanJson>> 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<DecodedTrace> traces = new ArrayList<>();
if (rawTraces != null) {
for (List<SpanJson> spans : rawTraces) {
List<DecodedSpan> decodedSpans = spans == null ? emptyList() : new ArrayList<>(spans);
traces.add(new TraceJson(decodedSpans));
}
}
return new MessageJson(traces);
}

@Override
public List<DecodedTrace> getTraces() {
return this.traces;
}
}
Original file line number Diff line number Diff line change
@@ -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<String, String> meta;

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

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, Double> 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;
}

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() : this.meta;
}

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

@Override
public Map<String, Number> 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
+ '}';
}
}
Original file line number Diff line number Diff line change
@@ -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<DecodedSpan> spans;

TraceJson(List<DecodedSpan> spans) {
this.spans = unmodifiableList(spans);
}

@Override
public List<DecodedSpan> getSpans() {
return this.spans;
}

@Override
public String toString() {
return this.spans.toString();
}
}
Loading