From d14912e8355aef96ea7f5cc4ebcc6cff0fefa768 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Tue, 28 Jul 2026 11:52:48 +0200 Subject: [PATCH 1/2] 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 | 61 +++++++ .../test/agent/decoder/json/raw/SpanJson.java | 145 +++++++++++++++ .../agent/decoder/json/raw/TraceJson.java | 26 +++ .../test/agent/decoder/JsonDecoderTest.java | 167 ++++++++++++++++++ 7 files changed, 410 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 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..10064a8ac38 --- /dev/null +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/MessageJson.java @@ -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>> ADAPTER = + new Moshi.Builder().build().adapter(LIST_OF_TRACES); + + private final List traces; + + private MessageJson(List traces) { + this.traces = traces; + } + + /** Decodes a JSON trace payload into a {@link MessageJson}. */ + public static MessageJson fromJson(String json) { + List> 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 traces = new ArrayList<>(); + if (rawTraces != null) { + for (List spans : rawTraces) { + List decodedSpans = spans == null ? emptyList() : new ArrayList<>(spans); + traces.add(new TraceJson(decodedSpans)); + } + } + return new MessageJson(traces); + } + + @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..5a22e72221a --- /dev/null +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/SpanJson.java @@ -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 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; + } + + @Override + public long getDuration() { + return this.duration; + } + + @Override + public int getError() { + return this.error; + } + + @Override + public Map getMeta() { + return this.meta == null ? emptyMap() : this.meta; + } + + @Override + public Map getMetaStruct() { + return this.metaStruct == null ? emptyMap() : this.metaStruct; + } + + @Override + public Map 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 + + '}'; + } +} 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..f35c677a4d0 --- /dev/null +++ b/utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/JsonDecoderTest.java @@ -0,0 +1,167 @@ +package datadog.trace.test.agent.decoder; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +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. + private static final String TWO_TRACES = + "[" + + " [" + + " {" + + " \"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\": {}" + + " }" + + " ]" + + "]"; + + @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(); + // Moshi decodes every JSON number in the metrics map as a Double, matching the Number model. + assertEquals(1.0, metrics.get("_dd.top_level").doubleValue()); + assertEquals(0.75, metrics.get("_dd.agent_psr").doubleValue()); + + // 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 = + "[[{" + + "\"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}}" + + "}]]"; + 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 malformedJsonThrows() { + IllegalStateException e = + assertThrows(IllegalStateException.class, () -> Decoder.decodeJson("{ not valid json")); + assertTrue(e.getMessage().contains("{ not valid json"), "message should include the body"); + } +} From 798798ab44d4f6a567e0cfad543a8eb4dd1ee718 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Tue, 28 Jul 2026 11:03:59 +0200 Subject: [PATCH 2/2] feat(testing): Add decoded span assert rules --- .../smoketest/trace/SmokeTraceAssertions.java | 173 ++++++++++ .../datadog/smoketest/trace/SpanMatcher.java | 317 ++++++++++++++++++ .../datadog/smoketest/trace/TraceMatcher.java | 149 ++++++++ .../smoketest/trace/SmokeMatcherTest.java | 246 ++++++++++++++ 4 files changed, 885 insertions(+) create mode 100644 dd-smoke-tests/src/main/java/datadog/smoketest/trace/SmokeTraceAssertions.java create mode 100644 dd-smoke-tests/src/main/java/datadog/smoketest/trace/SpanMatcher.java create mode 100644 dd-smoke-tests/src/main/java/datadog/smoketest/trace/TraceMatcher.java create mode 100644 dd-smoke-tests/src/test/java/datadog/smoketest/trace/SmokeMatcherTest.java diff --git a/dd-smoke-tests/src/main/java/datadog/smoketest/trace/SmokeTraceAssertions.java b/dd-smoke-tests/src/main/java/datadog/smoketest/trace/SmokeTraceAssertions.java new file mode 100644 index 00000000000..cb48941c5d8 --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/trace/SmokeTraceAssertions.java @@ -0,0 +1,173 @@ +package datadog.smoketest.trace; + +import static java.lang.Long.MAX_VALUE; +import static java.lang.Math.min; +import static java.util.function.UnaryOperator.identity; +import static org.junit.jupiter.api.AssertionFailureBuilder.assertionFailure; + +import datadog.trace.test.agent.decoder.DecodedSpan; +import datadog.trace.test.agent.decoder.DecodedTrace; +import java.util.ArrayList; +import java.util.BitSet; +import java.util.Comparator; +import java.util.List; +import java.util.function.UnaryOperator; + +/** + * This class is a helper class to verify trace structure. + * + *

To check for trace structure, use the static factory methods: {@link #assertTraces(List, + * TraceMatcher...)} with the expected {@link TraceMatcher}s (one per trace), or {@link + * #assertTraces(List, UnaryOperator, TraceMatcher...)} to configure the checks with a {@link + * Options} object. + * + *

The following predefined configurations: + * + *

    + *
  • {@link #IGNORE_ADDITIONAL_TRACES} ignores additional traces if there are more than + * expected, + *
  • {@link #SORT_BY_START_TIME} sorts traces by their start time, + *
  • {@link #UNORDERED} allows matchers to match any distinct trace rather than the one at its + * position. + *
+ */ +public final class SmokeTraceAssertions { + /* + * Trace comparators. + */ + /** Trace comparator to sort by start time. */ + public static final Comparator TRACE_START_TIME_COMPARATOR = + Comparator.comparingLong(SmokeTraceAssertions::earliestStart); + + /* + * Trace assertion options. + */ + /** Ignores additional traces. If there are more traces than expected, do not fail. */ + public static final UnaryOperator IGNORE_ADDITIONAL_TRACES = + Options::ignoreAdditionalTraces; + + /** Allows matchers to match any distinct trace rather than the one at its position. */ + public static final UnaryOperator UNORDERED = Options::unorder; + + /** Sorts traces by start time. */ + public static final UnaryOperator SORT_BY_START_TIME = + options -> options.sort(TRACE_START_TIME_COMPARATOR); + + private SmokeTraceAssertions() {} + + /** + * Checks the structure of a trace collection. + * + * @param traces The trace collection to check. + * @param matchers The matchers to verify the trace collection, one matcher by expected trace. + */ + public static void assertTraces(List traces, TraceMatcher... matchers) { + assertTraces(traces, identity(), matchers); + } + + /** + * Checks the structure of a trace collection. + * + * @param traces The trace collection to check. + * @param options The {@link Options} to configure the checks. + * @param matchers The matchers to verify the trace collection, one matcher by expected trace. + */ + public static void assertTraces( + List traces, UnaryOperator options, TraceMatcher... matchers) { + Options opts = options.apply(new Options()); + // Check trace count first + int traceCount = traces.size(); + if (opts.ignoreAdditionalTraces) { + if (traceCount < matchers.length) { + assertionFailure() + .message("Not enough of traces") + .expected(matchers.length) + .actual(traceCount) + .buildAndThrow(); + } + } else { + if (traceCount != matchers.length) { + assertionFailure() + .message("Invalid number of traces") + .expected(matchers.length) + .actual(traceCount) + .buildAndThrow(); + } + } + // Apply sorter + if (opts.sorter != null) { + traces = new ArrayList<>(traces); + traces.sort(opts.sorter); + } + // Assert traces + boolean strictPositional = !opts.unordered && !opts.ignoreAdditionalTraces; + BitSet skippedTraces = new BitSet(traceCount); + for (int matcherIndex = 0; matcherIndex < matchers.length; matcherIndex++) { + TraceMatcher matcher = matchers[matcherIndex]; + if (strictPositional) { + matcher.assertTrace(traces.get(matcherIndex).getSpans(), matcherIndex); + continue; + } + boolean matched = false; + for (int traceIndex = skippedTraces.nextClearBit(0); traceIndex < traceCount; traceIndex++) { + if (skippedTraces.get(traceIndex)) { + continue; + } + try { + matcher.assertTrace(traces.get(traceIndex).getSpans(), matcherIndex); + matched = true; + if (opts.unordered) { + skippedTraces.set(traceIndex); + } else { + skippedTraces.set(0, traceIndex + 1); + } + break; + } catch (AssertionError ignored) { + // Swallow assertion errors, keep looking for a match + } + } + if (!matched) { + assertionFailure().message("No trace matches matcher # " + matcherIndex).buildAndThrow(); + } + } + } + + private static long earliestStart(DecodedTrace trace) { + long start = MAX_VALUE; + for (DecodedSpan span : trace.getSpans()) { + start = min(start, span.getStart()); + } + return start == MAX_VALUE ? 0L : start; + } + + public static final class Options { + boolean ignoreAdditionalTraces = false; + boolean unordered = false; + Comparator sorter = null; + + public Options ignoreAdditionalTraces() { + this.ignoreAdditionalTraces = true; + return this; + } + + /** + * Matches each matcher against any distinct trace rather than the one at its position. + * + *

Assignment is greedy: each matcher takes the first still-unmatched trace it accepts. This + * can spuriously fail when matchers accept overlapping sets of traces and a valid distinct + * assignment exists only under a different pairing. Keep matchers specific enough to be + * unambiguous. + */ + public Options unorder() { + this.unordered = true; + this.sorter = null; + return this; + } + + public Options sort(Comparator sorter) { + this.unordered = false; + this.sorter = sorter; + return this; + } + } +} diff --git a/dd-smoke-tests/src/main/java/datadog/smoketest/trace/SpanMatcher.java b/dd-smoke-tests/src/main/java/datadog/smoketest/trace/SpanMatcher.java new file mode 100644 index 00000000000..d1b6ca046d5 --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/trace/SpanMatcher.java @@ -0,0 +1,317 @@ +package datadog.smoketest.trace; + +import static datadog.trace.test.junit.utils.assertions.Matchers.assertValue; +import static datadog.trace.test.junit.utils.assertions.Matchers.is; +import static datadog.trace.test.junit.utils.assertions.Matchers.isFalse; +import static datadog.trace.test.junit.utils.assertions.Matchers.isTrue; +import static datadog.trace.test.junit.utils.assertions.Matchers.matches; +import static datadog.trace.test.junit.utils.assertions.Matchers.validates; + +import datadog.trace.test.agent.decoder.DecodedSpan; +import datadog.trace.test.junit.utils.assertions.Matcher; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.function.Predicate; +import java.util.regex.Pattern; + +/** + * The class is a helper class to verify span attributes on the {@link DecodedSpan} model produced + * by both smoke backends (the in-process mock agent and the dd-apm-test-agent). + * + *

To get a {@code SpanMatcher}, use the static factory method {@link #span()} and use it as a + * fluent builder to define the span matching constraints. + * + *

Span matching constraints cover only what a span carries once serialized: + * + *

    + *
  • span service name with {@link #service(String)} + *
  • span operation name with {@link #operationName(String)} and {@link #operationName(Pattern)} + *
  • span resource name with {@link #resourceName(String)}, {@link #resourceName(Pattern)}, and + * {@link #resourceName(Predicate)} + *
  • span type with {@link #type(String)} + *
  • span error status with {@link #error(boolean)} + *
  • span parent linkage with {@link #root()}, {@link #childOf(long)}, {@link + * #childOfIndex(int)}, and {@link #childOfPrevious()} + *
  • span meta (string) tags with {@link #tag(String, Matcher)} and {@link #tag(String, String)} + *
  • span metrics (numeric) tags with {@link #metric(String, Matcher)} + *
  • span meta_struct (nested structured data) with {@link #metaStruct(String, Matcher)} + *
+ */ +public final class SpanMatcher { + private Matcher serviceMatcher; + private Matcher operationNameMatcher; + private Matcher resourceNameMatcher; + private Matcher typeMatcher; + private Matcher errorMatcher; + private Matcher parentIdMatcher; + private int parentSpanIndex; + private final Map> metaMatchers; + private final Map> metricMatchers; + private final Map> metaStructMatchers; + + private static final Matcher CHILD_OF_PREVIOUS_MATCHER = is(0L); + + private SpanMatcher() { + this.parentSpanIndex = -1; + this.metaMatchers = new HashMap<>(); + this.metricMatchers = new HashMap<>(); + this.metaStructMatchers = new HashMap<>(); + } + + /** + * Checks a span and its attributes. + * + * @return A new {@link SpanMatcher} instance to configure span matching constraints. + */ + public static SpanMatcher span() { + return new SpanMatcher(); + } + + /** + * Checks the span service name matches the given value. + * + * @param service The service name to match against. + * @return The current {@link SpanMatcher} instance updated with the specified service name + * constraint. + */ + public SpanMatcher service(String service) { + this.serviceMatcher = is(service); + return this; + } + + /** + * Checks the span operation name matches the given value. + * + * @param operationName The operation name to match against. + * @return The current {@link SpanMatcher} instance updated with the specified operation name + * constraint. + */ + public SpanMatcher operationName(String operationName) { + this.operationNameMatcher = is(operationName); + return this; + } + + /** + * Checks the span operation name matches the provided regular expression pattern. + * + * @param pattern The {@link Pattern} to match the operation name against. + * @return The current {@link SpanMatcher} instance updated with the specified operation name + * constraint. + */ + public SpanMatcher operationName(Pattern pattern) { + this.operationNameMatcher = matches(pattern); + return this; + } + + /** + * Checks the span resource name matches the given value. + * + * @param resourceName The resource name to match against. + * @return The current {@link SpanMatcher} instance updated with the specified resource name + * constraint. + */ + public SpanMatcher resourceName(String resourceName) { + this.resourceNameMatcher = is(resourceName); + return this; + } + + /** + * Checks the span resource name matches the provided regular expression pattern. + * + * @param pattern The {@link Pattern} used to match the resource name against. + * @return The current {@link SpanMatcher} instance updated with the specified resource name + * constraint. + */ + public SpanMatcher resourceName(Pattern pattern) { + this.resourceNameMatcher = matches(pattern); + return this; + } + + /** + * Checks the span resource name matches the provided validator. + * + * @param validator The {@link Predicate} used to validate the resource name. + * @return The current {@link SpanMatcher} instance updated with the specified resource name + * constraint. + */ + public SpanMatcher resourceName(Predicate validator) { + this.resourceNameMatcher = validates(validator); + return this; + } + + /** + * Checks the span type matches the given value. + * + * @param type The span type to match against. + * @return The current {@link SpanMatcher} instance updated with the specified span type + * constraint. + */ + public SpanMatcher type(String type) { + this.typeMatcher = is(type); + return this; + } + + /** + * Checks the span error status matches the given value. + * + * @param errored The expected error status. + * @return The current {@link SpanMatcher} instance updated with the specified error constraint. + */ + public SpanMatcher error(boolean errored) { + this.errorMatcher = errored ? isTrue() : isFalse(); + return this; + } + + /** + * Checks the span is a root span (i.e., a span with no parent). + * + * @return The current {@link SpanMatcher} instance with the root constraint applied. + */ + public SpanMatcher root() { + this.parentIdMatcher = is(0L); + this.parentSpanIndex = -1; + return this; + } + + /** + * Checks the span is a direct child of the specified parent span. + * + * @param parentSpanId The identifier of the parent span to match against. + * @return The current {@link SpanMatcher} instance with the child-of constraint applied. + */ + public SpanMatcher childOf(long parentSpanId) { + this.parentIdMatcher = is(parentSpanId); + this.parentSpanIndex = -1; + return this; + } + + /** + * Checks the span is a direct child of the span at the specified index in the trace. + * + * @param parentSpanIndex The index of the parent span in the trace. + * @return The current {@link SpanMatcher} instance with the child-of constraint applied. + */ + public SpanMatcher childOfIndex(int parentSpanIndex) { + this.parentIdMatcher = null; + this.parentSpanIndex = parentSpanIndex; + return this; + } + + /** + * Checks the span is a direct child of the immediately preceding span in the trace. + * + * @return The current {@link SpanMatcher} instance with the child-of constraint applied. + */ + public SpanMatcher childOfPrevious() { + this.parentIdMatcher = CHILD_OF_PREVIOUS_MATCHER; + this.parentSpanIndex = -1; + return this; + } + + /** + * Checks the span meta (string) tag matches the given matcher. + * + * @param name The name of the meta tag to match against. + * @param matcher The matcher to check the meta tag value. + * @return The current {@link SpanMatcher} instance updated with the specified meta tag + * constraint. + */ + public SpanMatcher tag(String name, Matcher matcher) { + this.metaMatchers.put(name, matcher); + return this; + } + + /** + * Checks the span meta (string) tag matches the given value. + * + * @param name The name of the meta tag to match against. + * @param value The expected meta tag value. + * @return The current {@link SpanMatcher} instance updated with the specified meta tag + * constraint. + */ + public SpanMatcher tag(String name, String value) { + this.metaMatchers.put(name, is(value)); + return this; + } + + /** + * Checks the span metric (numeric) tag matches the given matcher. + * + * @param name The name of the metric tag to match against. + * @param matcher The matcher to check the metric tag value. + * @return The current {@link SpanMatcher} instance updated with the specified metric tag + * constraint. + */ + public SpanMatcher metric(String name, Matcher matcher) { + this.metricMatchers.put(name, matcher); + return this; + } + + /** + * Checks the span meta_struct entry matches the given matcher. + * + * @param name The name of the meta_struct entry to match against. + * @param matcher The matcher to check the meta_struct entry value. + * @return The current {@link SpanMatcher} instance updated with the specified meta_struct + * constraint. + */ + public SpanMatcher metaStruct(String name, Matcher matcher) { + this.metaStructMatchers.put(name, matcher); + return this; + } + + void assertSpan(List trace, int spanIndex) { + DecodedSpan span = trace.get(spanIndex); + assertValue(this.serviceMatcher, span.getService(), "Unexpected service name"); + assertValue(this.operationNameMatcher, span.getName(), "Unexpected operation name"); + assertValue(this.resourceNameMatcher, span.getResource(), "Unexpected resource name"); + assertValue(this.typeMatcher, span.getType(), "Unexpected span type"); + assertValue(this.errorMatcher, span.getError() != 0, "Unexpected error status"); + assertValue(parentIdMatcher(trace, spanIndex), span.getParentId(), "Unexpected parent id"); + assertSpanTags(span.getMeta()); + assertSpanMetrics(span.getMetrics()); + assertSpanMetaStruct(span.getMetaStruct()); + } + + private Matcher parentIdMatcher(List trace, int spanIndex) { + if (this.parentSpanIndex >= 0) { + return is(trace.get(this.parentSpanIndex).getSpanId()); + } else if (this.parentIdMatcher == CHILD_OF_PREVIOUS_MATCHER) { + if (spanIndex == 0) { + throw new IllegalStateException("Cannot use childOfPrevious() matcher on the first span"); + } + return is(trace.get(spanIndex - 1).getSpanId()); + } else { + return this.parentIdMatcher; + } + } + + private void assertSpanTags(Map meta) { + for (Entry> entry : this.metaMatchers.entrySet()) { + String key = entry.getKey(); + assertValue(entry.getValue(), meta.get(key), "Unexpected meta tag '" + key + "'"); + } + } + + private void assertSpanMetrics(Map metrics) { + for (Entry> entry : this.metricMatchers.entrySet()) { + assertValue( + entry.getValue(), + metrics.get(entry.getKey()), + "Unexpected metric '" + entry.getKey() + "'"); + } + } + + @SuppressWarnings("unchecked") + private void assertSpanMetaStruct(Map metaStruct) { + for (Entry> entry : this.metaStructMatchers.entrySet()) { + String key = entry.getKey(); + assertValue( + (Matcher) entry.getValue(), + metaStruct.get(key), + "Unexpected meta_struct '" + key + "'"); + } + } +} diff --git a/dd-smoke-tests/src/main/java/datadog/smoketest/trace/TraceMatcher.java b/dd-smoke-tests/src/main/java/datadog/smoketest/trace/TraceMatcher.java new file mode 100644 index 00000000000..0ad9fb86bed --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/trace/TraceMatcher.java @@ -0,0 +1,149 @@ +package datadog.smoketest.trace; + +import static java.util.Comparator.comparingLong; +import static java.util.stream.Collectors.toSet; + +import datadog.trace.test.agent.decoder.DecodedSpan; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.UnaryOperator; +import org.opentest4j.AssertionFailedError; + +/** + * This class is a helper class to verify a trace structure. + * + *

To get a {@link TraceMatcher}, use the static factory methods: {@link #trace(SpanMatcher...)} + * with the expected {@link SpanMatcher}s (one per expected span), or {@link #trace(UnaryOperator, + * SpanMatcher...)} to configure the checks with a {@link Options} object. + * + *

The following predefined configurations: + * + *

    + *
  • {@link #SORT_BY_START_TIME} sorts spans by start time, + *
  • {@link #SORT_BY_ANCESTRY} sorts spans by ancestry, root spans (or which parents are not + * present in the trace chunk) first, followed by their children by start time, depth-first + *
+ * + * @see SmokeTraceAssertions + * @see SpanMatcher + */ +public final class TraceMatcher { + /* + * Span comparators. + */ + /** Span comparator to sort by start time. */ + public static final Comparator START_TIME_COMPARATOR = + comparingLong(DecodedSpan::getStart).thenComparingLong(DecodedSpan::getSpanId); + + /* + * Span assertion options. + */ + /** Sorts spans by start time. */ + public static final UnaryOperator SORT_BY_START_TIME = + options -> options.sort(START_TIME_COMPARATOR); + + /** + * Sorts spans by ancestry, root spans (or which parents are absent from the trace chunk) first, + * followed by their children by start time, depth-first. + */ + public static final UnaryOperator SORT_BY_ANCESTRY = Options::sortByAncestry; + + private final Options options; + private final SpanMatcher[] matchers; + + private TraceMatcher(Options options, SpanMatcher[] spanMatchers) { + if (spanMatchers.length == 0) { + throw new IllegalArgumentException("No span matchers provided"); + } + this.options = options; + this.matchers = spanMatchers; + } + + /** + * Checks a trace structure. + * + * @param matchers The matchers to verify the trace structure. + */ + public static TraceMatcher trace(SpanMatcher... matchers) { + return new TraceMatcher(new Options(), matchers); + } + + /** + * Checks a trace structure. + * + * @param options The {@link TraceMatcher.Options} to configure the checks. + * @param matchers The matchers to verify the trace structure. + */ + public static TraceMatcher trace(UnaryOperator options, SpanMatcher... matchers) { + return new TraceMatcher(options.apply(new Options()), matchers); + } + + void assertTrace(List spans, int traceIndex) { + if (spans.size() != this.matchers.length) { + throw new AssertionFailedError( + "Invalid number of spans for trace " + traceIndex + " : " + spans, + this.matchers.length, + spans.size()); + } + if (this.options.sortByAncestry) { + spans = sortByAncestry(spans); + } else if (this.options.comparator != null) { + spans = new ArrayList<>(spans); + spans.sort(this.options.comparator); + } + for (int i = 0; i < this.matchers.length; i++) { + this.matchers[i].assertSpan(spans, i); + } + } + + private static List sortByAncestry(List spans) { + Set spanIds = spans.stream().map(DecodedSpan::getSpanId).collect(toSet()); + Map> spansByParentId = new HashMap<>(); + for (DecodedSpan span : spans) { + long parentId = span.getParentId(); + if (parentId != 0 && !spanIds.contains(parentId)) { + parentId = 0; + } + spansByParentId.computeIfAbsent(parentId, k -> new ArrayList<>()).add(span); + } + spansByParentId.forEach((k, v) -> v.sort(START_TIME_COMPARATOR)); + + List ordered = new ArrayList<>(spans.size()); + appendChildren(ordered, spansByParentId.get(0L), spansByParentId); + return ordered; + } + + private static void appendChildren( + List orderedSpan, + List children, + Map> spansByParentId) { + for (DecodedSpan child : children) { + orderedSpan.add(child); + List grandChildren = spansByParentId.get(child.getSpanId()); + if (grandChildren != null) { + appendChildren(orderedSpan, grandChildren, spansByParentId); + } + } + } + + public static final class Options { + private Comparator comparator = null; + private boolean sortByAncestry = false; + + public Options sort(Comparator comparator) { + this.comparator = comparator; + this.sortByAncestry = false; + return this; + } + + Options sortByAncestry() { + this.comparator = null; + this.sortByAncestry = true; + return this; + } + } +} diff --git a/dd-smoke-tests/src/test/java/datadog/smoketest/trace/SmokeMatcherTest.java b/dd-smoke-tests/src/test/java/datadog/smoketest/trace/SmokeMatcherTest.java new file mode 100644 index 00000000000..7da9d3a90e6 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/trace/SmokeMatcherTest.java @@ -0,0 +1,246 @@ +package datadog.smoketest.trace; + +import static datadog.smoketest.trace.SmokeTraceAssertions.IGNORE_ADDITIONAL_TRACES; +import static datadog.smoketest.trace.SmokeTraceAssertions.assertTraces; +import static datadog.smoketest.trace.SpanMatcher.span; +import static datadog.smoketest.trace.TraceMatcher.SORT_BY_ANCESTRY; +import static datadog.smoketest.trace.TraceMatcher.SORT_BY_START_TIME; +import static datadog.smoketest.trace.TraceMatcher.trace; +import static datadog.trace.test.junit.utils.assertions.Matchers.isNonNull; +import static datadog.trace.test.junit.utils.assertions.Matchers.validates; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import datadog.trace.test.agent.decoder.DecodedTrace; +import datadog.trace.test.agent.decoder.Decoder; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Docker-free tests for the structural matcher extensions (span sorting + {@code childOfIndex} / + * {@code childOfPrevious}, and trace-collection sorting / {@code IGNORE_ADDITIONAL_TRACES}). Traces + * are built synthetically through the S1b JSON decoder so no backend is needed. + */ +class SmokeMatcherTest { + + // One trace, three spans forming root -> child -> grandchild, but delivered out of start order. + private static final String CHAIN_TRACE = + "[[" + + spanJson("grandchild", 300, 200, 30) + + "," + + spanJson("root", 100, 0, 10) + + "," + + spanJson("child", 200, 100, 20) + + "]]"; + + // Two single-span traces; root-b (id 400) has a smaller root span id than root-a (id 500). + private static final String TWO_TRACES = + "[[" + spanJson("root-a", 500, 0, 10) + "],[" + spanJson("root-b", 400, 0, 20) + "]]"; + + // One trace whose root has two children (not a linear chain). + private static final String BRANCHING_TRACE = + "[[" + + spanJson("root", 100, 0, 10) + + "," + + spanJson("a", 200, 100, 20) + + "," + + spanJson("b", 300, 100, 30) + + "]]"; + + // One span carrying meta_struct: an IAST-style nested "_dd.stack" plus a request-body entry. + private static final String META_STRUCT_TRACE = + "[[{" + + "\"service\":\"s\",\"name\":\"servlet.request\",\"resource\":\"r\",\"type\":\"web\"," + + "\"trace_id\":1,\"span_id\":1,\"parent_id\":0,\"start\":0,\"duration\":1,\"error\":0," + + "\"meta\":{},\"metrics\":{}," + + "\"meta_struct\":{" + + "\"_dd.stack\":{\"vulnerability\":[{\"type\":\"SQL_INJECTION\"},{\"type\":\"XSS\"}]}," + + "\"http.request.body\":{\"foo\":\"bar\"}" + + "}}]]"; + + @Test + void sortsSpansByStartTimeThenMatchesParentByPrevious() { + List traces = Decoder.decodeJson(CHAIN_TRACE).getTraces(); + assertTraces( + traces, + trace( + SORT_BY_START_TIME, + span().operationName("root").root(), + span().operationName("child").childOfPrevious(), + span().operationName("grandchild").childOfPrevious())); + } + + @Test + void matchesParentByIndex() { + List traces = Decoder.decodeJson(CHAIN_TRACE).getTraces(); + assertTraces( + traces, + trace( + SORT_BY_START_TIME, + span().operationName("root").root(), + span().operationName("child").childOfIndex(0), + span().operationName("grandchild").childOfIndex(1))); + } + + @Test + void wrongParentLinkFails() { + List traces = Decoder.decodeJson(CHAIN_TRACE).getTraces(); + // grandchild's parent is child (index 1), not root (index 0). + assertThrows( + AssertionError.class, + () -> + assertTraces( + traces, + trace( + SORT_BY_START_TIME, + span().operationName("root").root(), + span().operationName("child").childOfIndex(0), + span().operationName("grandchild").childOfIndex(0)))); + } + + @Test + void ignoresAdditionalTraces() { + List traces = Decoder.decodeJson(TWO_TRACES).getTraces(); + // Assert just the first received trace, ignoring the other. + assertTraces(traces, IGNORE_ADDITIONAL_TRACES, trace(span().operationName("root-a").root())); + } + + @Test + void sortsByAncestryRegardlessOfStartTime() { + List traces = Decoder.decodeJson(CHAIN_TRACE).getTraces(); + // Spans arrive out of start order; ancestry order recovers root -> child -> grandchild. + assertTraces( + traces, + trace( + SORT_BY_ANCESTRY, + span().operationName("root").root(), + span().operationName("child").childOfPrevious(), + span().operationName("grandchild").childOfPrevious())); + } + + @Test + void ancestryOrdersBranchingTraceParentBeforeChildrenByStart() { + List traces = Decoder.decodeJson(BRANCHING_TRACE).getTraces(); + // root, then its two children in start order (a@20 before b@30); both are children of root. + assertTraces( + traces, + trace( + SORT_BY_ANCESTRY, + span().operationName("root").root(), + span().operationName("a").childOfIndex(0), + span().operationName("b").childOfIndex(0))); + } + + @Test + void unorderedMatchesAnyOrder() { + List traces = Decoder.decodeJson(TWO_TRACES).getTraces(); + // Matchers in the opposite order of receipt still each find their trace (no sorter => any + // order). + assertTraces( + traces, + options -> options.unorder().ignoreAdditionalTraces(), + trace(span().operationName("root-b").root()), + trace(span().operationName("root-a").root())); + } + + @Test + void unorderedRequiresDistinctTraces() { + List traces = Decoder.decodeJson(TWO_TRACES).getTraces(); + // Two matchers for the same trace can't both match: only one root-a trace exists. + assertThrows( + AssertionError.class, + () -> + assertTraces( + traces, + options -> options.unorder().ignoreAdditionalTraces(), + trace(span().operationName("root-a").root()), + trace(span().operationName("root-a").root()))); + } + + @Test + void matchesMetaStructByMatcherAndPredicate() { + List traces = Decoder.decodeJson(META_STRUCT_TRACE).getTraces(); + assertTraces( + traces, + trace( + span() + .operationName("servlet.request") + .root() + // plain matcher: the entry is present (any nested value) + .metaStruct("http.request.body", isNonNull()) + // predicate via validates(): navigate the nested structure + .metaStruct( + "_dd.stack", + validates(v -> ((List) ((Map) v).get("vulnerability")).size() == 2)))); + } + + @Test + void metaStructMismatchFails() { + List traces = Decoder.decodeJson(META_STRUCT_TRACE).getTraces(); + // Absent entry -> null value -> matcher fails. + assertThrows( + AssertionError.class, + () -> assertTraces(traces, trace(span().root().metaStruct("does.not.exist", isNonNull())))); + // Present entry but the predicate over its nested value is not satisfied. + assertThrows( + AssertionError.class, + () -> + assertTraces( + traces, + trace( + span() + .root() + .metaStruct( + "_dd.stack", + validates( + v -> + ((List) ((Map) v).get("vulnerability")).size() + == 99))))); + } + + @Test + void ignoreAdditionalConsumesMatchedTrace() { + // Two matchers demanding the same trace must not both be satisfied by a single matching trace: + // the extra (root-b) is ignored, so once root-a is consumed the second matcher has nothing + // left. + List traces = Decoder.decodeJson(TWO_TRACES).getTraces(); + assertThrows( + AssertionError.class, + () -> + assertTraces( + traces, + IGNORE_ADDITIONAL_TRACES, + trace(span().operationName("root-a").root()), + trace(span().operationName("root-a").root()))); + } + + @Test + void childOfPreviousDoesNotLeakAcrossCandidates() { + // A broken chain (2 spans) is tried first: its childOfPrevious span resolves a concrete parent + // id and then fails. The valid chain that follows uses different span ids and must still match + // — + // the matcher must not carry the first candidate's ids over to the next. + String broken = "[" + spanJson("root", 10, 0, 10) + "," + spanJson("child", 11, 999, 20) + "]"; + String valid = "[" + spanJson("root", 20, 0, 10) + "," + spanJson("child", 21, 20, 20) + "]"; + List traces = Decoder.decodeJson("[" + broken + "," + valid + "]").getTraces(); + assertTraces( + traces, + options -> options.unorder().ignoreAdditionalTraces(), + trace( + span().operationName("root").root(), span().operationName("child").childOfPrevious())); + } + + private static String spanJson(String name, long id, long parent, long start) { + return "{\"service\":\"s\",\"name\":\"" + + name + + "\",\"resource\":\"" + + name + + "\",\"type\":\"web\",\"trace_id\":1,\"span_id\":" + + id + + ",\"parent_id\":" + + parent + + ",\"start\":" + + start + + ",\"duration\":1,\"error\":0,\"meta\":{},\"metrics\":{}}"; + } +}