-
Notifications
You must be signed in to change notification settings - Fork 347
Add APM test agent trace decoding #12089
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: bbujon/smoke-tests-decoded-spans
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. | ||
| */ | ||
| 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; | ||
|
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a trace contains Useful? React with 👍 / 👎.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It does not apply here. From Claude:
|
||
|
|
||
| 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; | ||
| } | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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
Was this helpful? React 👍 or 👎
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| + '\'' | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: not sure, but probably |
||
| + ", 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 | ||
| + '}'; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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?