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..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)}
+ *
+ */
+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