From d100b5a19538fd31c87f5ea6997288e092fa8cb2 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Tue, 28 Jul 2026 11:03:59 +0200 Subject: [PATCH] feat(testing): Add decoded span assert rules --- .../smoketest/trace/SmokeTraceAssertions.java | 173 ++++++++++ .../datadog/smoketest/trace/SpanMatcher.java | 324 ++++++++++++++++++ .../datadog/smoketest/trace/TraceMatcher.java | 149 ++++++++ .../smoketest/trace/SmokeMatcherTest.java | 279 +++++++++++++++ 4 files changed, 925 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: + * + *

+ */ +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..d58d50fb60b --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/trace/SpanMatcher.java @@ -0,0 +1,324 @@ +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.isNull; +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.serviceMatcher = validates(s -> s != null && !s.isEmpty()); + this.typeMatcher = isNull(); + this.errorMatcher = isFalse(); + 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) { + if (parentSpanIndex < 0) { + throw new IllegalArgumentException("index must be >= 0"); + } + 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..3eaf2af05b8 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/trace/SmokeMatcherTest.java @@ -0,0 +1,279 @@ +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\"," + + "\"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())); + } + + @Test + void defaultConstraintsRequireDefinedServiceNoTypeAndNoError() { + // A bare span() enforces the default conditions: service defined, no span type, not errored. + assertTraces( + Decoder.decodeJson("[[" + spanJson("op", 1, 0, 0) + "]]").getTraces(), + trace(span().root())); + + // A non-null span type fails the default unless acknowledged with type(...). + List typed = Decoder.decodeJson(spanTrace("s", "web", 0)).getTraces(); + assertThrows(AssertionError.class, () -> assertTraces(typed, trace(span().root()))); + assertTraces(typed, trace(span().root().type("web"))); + + // An errored span fails the default unless acknowledged with error(true). + List errored = Decoder.decodeJson(spanTrace("s", null, 1)).getTraces(); + assertThrows(AssertionError.class, () -> assertTraces(errored, trace(span().root()))); + assertTraces(errored, trace(span().root().error(true))); + + // An undefined (empty) service fails the default. + List noService = Decoder.decodeJson(spanTrace("", null, 0)).getTraces(); + assertThrows(AssertionError.class, () -> assertTraces(noService, trace(span().root()))); + } + + /** A single-span trace with a configurable service, span type (nullable), and error flag. */ + private static String spanTrace(String service, String type, int error) { + return "[[{\"service\":\"" + + service + + "\",\"name\":\"op\",\"resource\":\"op\"," + + (type == null ? "" : "\"type\":\"" + type + "\",") + + "\"trace_id\":1,\"span_id\":1,\"parent_id\":0,\"start\":0,\"duration\":1,\"error\":" + + error + + ",\"meta\":{},\"metrics\":{}}]]"; + } + + private static String spanJson(String name, long id, long parent, long start) { + return "{\"service\":\"s\",\"name\":\"" + + name + + "\",\"resource\":\"" + + name + + "\",\"trace_id\":1,\"span_id\":" + + id + + ",\"parent_id\":" + + parent + + ",\"start\":" + + start + + ",\"duration\":1,\"error\":0,\"meta\":{},\"metrics\":{}}"; + } +}