Skip to content

Commit 783fae3

Browse files
committed
WIP
1 parent f0de7f2 commit 783fae3

6 files changed

Lines changed: 276 additions & 216 deletions

File tree

dd-smoke-tests/spring-boot-rabbit/src/test/java/datadog/smoketest/SpringBootRabbitSmokeTest.java

Lines changed: 51 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
11
package datadog.smoketest;
22

3+
import static datadog.smoketest.trace.SmokeTraceAssertions.IGNORE_ADDITIONAL_TRACES;
34
import static datadog.smoketest.trace.SpanMatcher.span;
5+
import static datadog.smoketest.trace.TraceMatcher.SORT_BY_PARENT_CHAIN;
6+
import static datadog.smoketest.trace.TraceMatcher.trace;
47
import static org.junit.jupiter.api.Assertions.assertEquals;
58

69
import datadog.smoketest.backend.EnabledIfDockerAvailable;
710
import datadog.smoketest.backend.TestAgentBackend;
811
import datadog.smoketest.backend.TraceBackend;
912
import datadog.smoketest.backend.Traces;
13+
import datadog.smoketest.trace.SmokeTraceAssertions;
1014
import datadog.smoketest.trace.SpanMatcher;
15+
import datadog.smoketest.trace.TraceMatcher;
1116
import datadog.trace.test.agent.decoder.DecodedSpan;
1217
import java.io.IOException;
18+
import java.util.ArrayList;
19+
import java.util.List;
1320
import okhttp3.OkHttpClient;
1421
import okhttp3.Request;
1522
import okhttp3.Response;
@@ -48,24 +55,27 @@
4855
* spring-rabbit-0 spring.consume Receiver.receiveMessage (sender consumes reply)
4956
* </pre>
5057
*
51-
* <p>Asserting that whole chain in one shot ({@link Traces#assertContainsChain}) is stronger than
52-
* the Groovy set-membership check: it verifies every AMQP operation (publish/deliver/consume, both
53-
* directions) <em>and</em> its cross-service linkage. The connection setup commands ({@code
54-
* basic.qos}/{@code basic.consume}/{@code queue.declare}) and {@code basic.ack} are separate
55-
* single-span traces, asserted per service.
58+
* <p>The whole collection is asserted with the standard {@link Traces#assertTraces} DSL in {@link
59+
* SmokeTraceAssertions#IGNORE_ADDITIONAL_TRACES order-independent subset} mode: each matcher
60+
* matches a distinct received trace and extras are ignored. This is stronger than the Groovy
61+
* set-membership check — each round-trip trace is matched count-exact (all 12 spans, in {@link
62+
* TraceMatcher#SORT_BY_PARENT_CHAIN parent-chain order}), verifying every AMQP operation
63+
* (publish/deliver/consume, both directions) <em>and</em> its cross-service linkage — while staying
64+
* robust to the timing-dependent extras (the broker emits its connection-setup commands and per-ack
65+
* traces as their own single-span traces, in non-deterministic count and order).
5666
*
57-
* <p>Two things the Groovy base did implicitly that this port makes explicit:
67+
* <p>Two design points, informed by inspecting the live trace collection:
5868
*
5969
* <ul>
70+
* <li><b>Parent-chain order, not start time</b> — the 12-span round-trip is a strict linear
71+
* chain, but its spans start within the same tick and race, so {@code SORT_BY_START_TIME} is
72+
* unstable across runs. {@code SORT_BY_PARENT_CHAIN} orders spans by parent links
73+
* (timestamp-independent) and asserts the trace is exactly that chain.
6074
* <li><b>Accumulate, don't isolate</b> — {@code .retainAcrossTests()} keeps traces from app
6175
* startup onward, because {@code basic.qos}/{@code basic.consume}/{@code queue.declare} are
6276
* emitted when the consumers start (before any test method), which a per-method session
63-
* {@code clear()} would discard. The Groovy base verified once at {@code cleanupSpec} against
64-
* everything written since launch; a single accumulating test method mirrors that.
65-
* <li><b>Membership, not count-exact</b> — {@code assertContainsChain} asserts the expected
66-
* structure is present and ignores the rest (as the Groovy set-membership check did): the
67-
* full distributed span set is large and timing-dependent, so a positional match over the
68-
* whole collection is the wrong tool.
77+
* {@code clear()} would discard. Mirrors the Groovy base verifying once at {@code
78+
* cleanupSpec} against everything since launch.
6979
* </ul>
7080
*/
7181
@EnabledIfDockerAvailable
@@ -113,15 +123,32 @@ void roundtripsProduceFullAmqpTraceStructure() throws IOException {
113123
}
114124
}
115125

116-
Traces traces = agent.traces();
126+
// One order-independent assertion over the whole collection (IGNORE_ADDITIONAL_TRACES): each
127+
// matcher must match a distinct received trace; unrelated/duplicate traces (extra acks, etc.)
128+
// are ignored. Assert one full round-trip trace per message plus each service's
129+
// connection-setup/ack commands.
130+
List<TraceMatcher> expected = new ArrayList<>();
131+
for (int i = 0; i < MESSAGES.length; i++) {
132+
expected.add(roundTrip()); // one full round-trip trace per message => all are verified
133+
}
134+
for (String service : new String[] {"spring-rabbit-0", "spring-rabbit-1"}) {
135+
for (String command : ADMIN_COMMANDS) {
136+
expected.add(admin(service, command));
137+
}
138+
}
139+
agent
140+
.traces()
141+
.assertTraces(
142+
TIMEOUT_SECONDS, IGNORE_ADDITIONAL_TRACES, expected.toArray(new TraceMatcher[0]));
143+
}
117144

118-
// Each round-trip is its own full distributed trace: a strict parent->child chain across both
119-
// services and the broker, from the sender's HTTP entrypoint to the sender consuming the
120-
// forwarded reply. Assert one such chain per message (MESSAGES.length), so all round-trips are
121-
// verified, not just one.
122-
traces.assertContainsChain(
123-
MESSAGES.length,
124-
TIMEOUT_SECONDS,
145+
// The full distributed round-trip as one strict parent->child chain across both services and the
146+
// broker. SORT_BY_PARENT_CHAIN orders the spans root->leaf and asserts the trace IS exactly this
147+
// linear chain: HTTP entrypoint -> publish -> receiver consumes+forwards -> sender consumes
148+
// reply.
149+
private static TraceMatcher roundTrip() {
150+
return trace(
151+
SORT_BY_PARENT_CHAIN,
125152
sp("spring-rabbit-0", "servlet.request", "GET /roundtrip/{message}").root(),
126153
sp("spring-rabbit-0", "spring.handler", "WebController.roundtrip"),
127154
sp("spring-rabbit-0", "amqp.command", "basic.publish <default> -> otherqueue"),
@@ -134,13 +161,11 @@ void roundtripsProduceFullAmqpTraceStructure() throws IOException {
134161
sp("spring-rabbit-0", "amqp.command", "basic.deliver queue"),
135162
sp("spring-rabbit-0", "amqp.consume", "amqp.consume queue"),
136163
sp("spring-rabbit-0", "spring.consume", "Receiver.receiveMessage"));
164+
}
137165

138-
// The connection-setup / ack commands each app emits as its own single-span trace.
139-
for (String service : new String[] {"spring-rabbit-0", "spring-rabbit-1"}) {
140-
for (String command : ADMIN_COMMANDS) {
141-
traces.assertContainsChain(TIMEOUT_SECONDS, sp(service, "amqp.command", command).root());
142-
}
143-
}
166+
// A connection-setup / ack command emitted as its own single-span (root) trace.
167+
private static TraceMatcher admin(String service, String command) {
168+
return trace(sp(service, "amqp.command", command).root());
144169
}
145170

146171
private static SpanMatcher sp(String service, String operation, String resource) {

dd-smoke-tests/src/main/java/datadog/smoketest/backend/Traces.java

Lines changed: 16 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
package datadog.smoketest.backend;
22

3+
import static java.util.function.UnaryOperator.identity;
4+
35
import datadog.smoketest.trace.SmokeTraceAssertions;
4-
import datadog.smoketest.trace.SpanMatcher;
56
import datadog.smoketest.trace.TraceMatcher;
67
import datadog.trace.test.agent.decoder.DecodedTrace;
78
import datadog.trace.test.util.PollingConditions;
@@ -56,18 +57,18 @@ public Traces waitForTraceCount(int count, double timeoutSeconds) {
5657
}
5758

5859
/**
59-
* Asserts the received traces against the thin smoke matcher — one {@link TraceMatcher} per
60-
* expected trace (matched positionally). Sugar over {@link SmokeTraceAssertions#assertTraces}:
60+
* Polls (up to the default timeout) until the received traces satisfy the thin smoke matcher —
61+
* one {@link TraceMatcher} per expected trace (matched positionally, count-exact). Polling
62+
* absorbs the async arrival of traces from a separately-launched app. Sugar over {@link
63+
* SmokeTraceAssertions#assertTraces}:
6164
*
6265
* <pre>{@code
63-
* app.traces().waitForTraceCount(1)
64-
* .assertTraces(trace(span().operationName("servlet.request").resourceName("GET /greeting")));
66+
* app.traces().assertTraces(
67+
* trace(span().operationName("servlet.request").resourceName("GET /greeting")));
6568
* }</pre>
6669
*/
6770
public void assertTraces(TraceMatcher... matchers) {
68-
int expectedTraceCount = matchers.length;
69-
waitForTraceCount(expectedTraceCount);
70-
SmokeTraceAssertions.assertTraces(getTraces(), matchers);
71+
assertTraces(identity(), matchers);
7172
}
7273

7374
/**
@@ -76,52 +77,16 @@ public void assertTraces(TraceMatcher... matchers) {
7677
*/
7778
public void assertTraces(
7879
UnaryOperator<SmokeTraceAssertions.Options> options, TraceMatcher... matchers) {
79-
SmokeTraceAssertions.assertTraces(getTraces(), options, matchers);
80+
assertTraces(DEFAULT_TIMEOUT_SECONDS, options, matchers);
8081
}
8182

82-
/**
83-
* Polls (up to the default timeout) until some received trace contains a parent-child chain of
84-
* spans matching {@code chain} (see {@link SmokeTraceAssertions#assertContainsChain}). Combines
85-
* waiting for async arrival with the structural assertion, so it suits distributed traces whose
86-
* full span set arrives piecemeal — assert the linkage you care about, ignore the rest:
87-
*
88-
* <pre>{@code
89-
* app.traces().assertContainsChain(
90-
* span().service("web").operationName("servlet.request").root(),
91-
* span().service("web").operationName("amqp.command").resourceName(startsWith("basic.publish")));
92-
* }</pre>
93-
*/
94-
public Traces assertContainsChain(SpanMatcher... chain) {
95-
return assertContainsChain(DEFAULT_TIMEOUT_SECONDS, chain);
96-
}
97-
98-
/** As {@link #assertContainsChain(SpanMatcher...)}, but overriding the timeout for this call. */
99-
public Traces assertContainsChain(double timeoutSeconds, SpanMatcher... chain) {
100-
new PollingConditions(timeoutSeconds)
101-
.eventually(() -> SmokeTraceAssertions.assertContainsChain(getTraces(), chain));
102-
return this;
103-
}
104-
105-
/**
106-
* Polls (up to {@code timeoutSeconds}) until at least {@code minMatches} received traces each
107-
* contain a chain matching {@code chain} (see {@link SmokeTraceAssertions#countChainMatches}).
108-
* Use to verify that N independent operations each produced the expected trace — e.g. one
109-
* distributed trace per request — not merely that one did.
110-
*/
111-
public Traces assertContainsChain(int minMatches, double timeoutSeconds, SpanMatcher... chain) {
83+
/** As {@link #assertTraces(UnaryOperator, TraceMatcher...)}, overriding the timeout. */
84+
public void assertTraces(
85+
double timeoutSeconds,
86+
UnaryOperator<SmokeTraceAssertions.Options> options,
87+
TraceMatcher... matchers) {
11288
new PollingConditions(timeoutSeconds)
113-
.eventually(
114-
() -> {
115-
long found = SmokeTraceAssertions.countChainMatches(getTraces(), chain);
116-
if (found < minMatches) {
117-
throw new AssertionError(
118-
"Expected at least "
119-
+ minMatches
120-
+ " traces containing the chain but found "
121-
+ found);
122-
}
123-
});
124-
return this;
89+
.eventually(() -> SmokeTraceAssertions.assertTraces(getTraces(), options, matchers));
12590
}
12691

12792
// Trace-invariant checks (ENABLED_CHECKS) are a test-agent-specific concern, validated by that

dd-smoke-tests/src/main/java/datadog/smoketest/trace/SmokeTraceAssertions.java

Lines changed: 61 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import datadog.trace.test.agent.decoder.DecodedTrace;
77
import java.util.ArrayList;
88
import java.util.Comparator;
9+
import java.util.Iterator;
910
import java.util.List;
1011
import java.util.function.UnaryOperator;
1112

@@ -21,10 +22,15 @@
2122
* assertTraces(traces, trace(span().operationName("servlet.request").resourceName("GET /greeting")));
2223
* }</pre>
2324
*
24-
* <p>Traces are matched positionally, in the order received by default. For order-independence pass
25-
* {@link #SORT_BY_START_TIME} / {@link #SORT_BY_ROOT_SPAN_ID}; to assert only a subset of the
26-
* received traces pass {@link #IGNORE_ADDITIONAL_TRACES}. The traces come from a {@code
27-
* TraceBackend} (mock or test-agent), both decoded to {@link DecodedTrace}.
25+
* <p>By default traces are matched <em>positionally</em> (matcher <i>i</i> ↔ trace <i>i</i>), in
26+
* the order received — pass {@link #SORT_BY_START_TIME} / {@link #SORT_BY_ROOT_SPAN_ID} for a
27+
* stable order, and the count must match exactly. Pass {@link #IGNORE_ADDITIONAL_TRACES} to switch
28+
* to <em>order-independent subset</em> matching instead: each matcher must match a
29+
* <em>distinct</em> received trace, and any extra traces are ignored — the right mode when the
30+
* collection contains unrelated or non-deterministically-ordered traces (e.g. a distributed test
31+
* where connection-setup commands land as their own traces alongside the request traces you care
32+
* about). The traces come from a {@code TraceBackend} (mock or test-agent), both decoded to {@link
33+
* DecodedTrace}.
2834
*/
2935
public final class SmokeTraceAssertions {
3036
/** Sorts traces by the earliest span start time. */
@@ -35,7 +41,10 @@ public final class SmokeTraceAssertions {
3541
public static final Comparator<List<DecodedSpan>> TRACE_ROOT_SPAN_ID_COMPARATOR =
3642
Comparator.comparingLong(SmokeTraceAssertions::rootSpanId);
3743

38-
/** Do not fail when there are more traces than matchers (assert a subset). */
44+
/**
45+
* Switch to order-independent subset matching: each matcher must match a distinct received trace,
46+
* and extra traces are ignored (instead of the default count-exact positional matching).
47+
*/
3948
public static final UnaryOperator<Options> IGNORE_ADDITIONAL_TRACES =
4049
Options::ignoreAdditionalTraces;
4150

@@ -58,94 +67,69 @@ public static void assertTraces(List<DecodedTrace> traces, TraceMatcher... match
5867
public static void assertTraces(
5968
List<DecodedTrace> traces, UnaryOperator<Options> options, TraceMatcher... matchers) {
6069
Options opts = options.apply(new Options());
61-
int expected = matchers.length;
62-
int actual = traces.size();
63-
if (opts.ignoreAdditionalTraces) {
64-
if (actual < expected) {
65-
throw new AssertionError("Expected at least " + expected + " traces but got " + actual);
66-
}
67-
} else if (actual != expected) {
68-
throw new AssertionError("Expected " + expected + " traces but got " + actual);
69-
}
7070
// Copy each trace's spans to a mutable list so the matcher can sort/inspect them.
71-
List<List<DecodedSpan>> spanLists = new ArrayList<>(actual);
71+
List<List<DecodedSpan>> spanLists = new ArrayList<>(traces.size());
7272
for (DecodedTrace trace : traces) {
7373
spanLists.add(new ArrayList<>(trace.getSpans()));
7474
}
75-
if (opts.sorter != null) {
76-
spanLists.sort(opts.sorter);
77-
}
78-
for (int i = 0; i < expected; i++) {
79-
matchers[i].assertTrace(spanLists.get(i), i);
75+
if (opts.ignoreAdditionalTraces) {
76+
assertContainsEach(spanLists, matchers);
77+
} else {
78+
assertPositionally(spanLists, opts, matchers);
8079
}
8180
}
8281

83-
/**
84-
* Asserts that some received trace contains a parent-child <em>chain</em> of spans matching
85-
* {@code chain} in order: {@code chain[0]} matches a span (a trace root if it declares {@link
86-
* SpanMatcher#root()}), and each {@code chain[i]} matches a direct child of {@code chain[i-1]}'s
87-
* span. Unlike {@link #assertTraces}, this is a <em>subset</em> match — extra spans in the trace
88-
* are ignored — which suits distributed traces whose full span set is large and timing-dependent
89-
* (only span linkage, not exact shape, is asserted). Field constraints are declared per span; the
90-
* chain declares the linkage, so the matchers should not also use {@code childOf*}.
91-
*/
92-
public static void assertContainsChain(List<DecodedTrace> traces, SpanMatcher... chain) {
93-
long found = countChainMatches(traces, chain);
94-
if (found == 0) {
82+
// Order-independent subset: each matcher must match a distinct received trace; extras are
83+
// ignored.
84+
private static void assertContainsEach(
85+
List<List<DecodedSpan>> spanLists, TraceMatcher[] matchers) {
86+
if (matchers.length > spanLists.size()) {
9587
throw new AssertionError(
96-
"No received trace contains a parent-child chain matching the "
97-
+ chain.length
98-
+ " given span matcher(s); traces: "
99-
+ traces);
100-
}
101-
}
102-
103-
/**
104-
* Counts how many received traces contain a parent-child chain matching {@code chain} (see {@link
105-
* #assertContainsChain(List, SpanMatcher...)} for the chain semantics). Use to verify N
106-
* independent operations each produced the expected trace — e.g. one distributed trace per
107-
* request — rather than just that a single one did.
108-
*/
109-
public static long countChainMatches(List<DecodedTrace> traces, SpanMatcher... chain) {
110-
if (chain.length == 0) {
111-
throw new IllegalArgumentException("countChainMatches requires at least one span matcher");
88+
"Expected at least "
89+
+ matchers.length
90+
+ " traces but got "
91+
+ spanLists.size()
92+
+ ": "
93+
+ spanLists);
11294
}
113-
long count = 0;
114-
for (DecodedTrace trace : traces) {
115-
if (containsChain(trace.getSpans(), chain)) {
116-
count++;
117-
}
118-
}
119-
return count;
120-
}
121-
122-
private static boolean containsChain(List<DecodedSpan> spans, SpanMatcher[] chain) {
123-
for (DecodedSpan first : spans) {
124-
if (chain[0].rootRequired() && first.getParentId() != 0L) {
125-
continue;
95+
// Greedily assign each matcher to a distinct, not-yet-consumed trace. Adequate for smoke tests;
96+
// overlapping matchers (one strictly more general than another) could mis-assign — write them
97+
// specific enough to be unambiguous.
98+
List<List<DecodedSpan>> remaining = new ArrayList<>(spanLists);
99+
for (int i = 0; i < matchers.length; i++) {
100+
boolean matched = false;
101+
for (Iterator<List<DecodedSpan>> it = remaining.iterator(); it.hasNext(); ) {
102+
if (matchers[i].matches(it.next(), i)) {
103+
it.remove();
104+
matched = true;
105+
break;
106+
}
126107
}
127-
if (chain[0].matchesFields(first) && matchesChainFrom(spans, chain, 1, first)) {
128-
return true;
108+
if (!matched) {
109+
throw new AssertionError(
110+
"No received trace matched trace matcher #"
111+
+ i
112+
+ " among "
113+
+ spanLists.size()
114+
+ " received trace(s): "
115+
+ spanLists);
129116
}
130117
}
131-
return false;
132118
}
133119

134-
// Depth-first, backtracking: extend the chain by a direct child of `parent` matching
135-
// chain[index].
136-
private static boolean matchesChainFrom(
137-
List<DecodedSpan> spans, SpanMatcher[] chain, int index, DecodedSpan parent) {
138-
if (index == chain.length) {
139-
return true;
120+
// Count-exact positional: matcher i ↔ trace i, after the optional trace sort.
121+
private static void assertPositionally(
122+
List<List<DecodedSpan>> spanLists, Options opts, TraceMatcher[] matchers) {
123+
if (spanLists.size() != matchers.length) {
124+
throw new AssertionError(
125+
"Expected " + matchers.length + " traces but got " + spanLists.size());
140126
}
141-
for (DecodedSpan child : spans) {
142-
if (child.getParentId() == parent.getSpanId()
143-
&& chain[index].matchesFields(child)
144-
&& matchesChainFrom(spans, chain, index + 1, child)) {
145-
return true;
146-
}
127+
if (opts.sorter != null) {
128+
spanLists.sort(opts.sorter);
129+
}
130+
for (int i = 0; i < matchers.length; i++) {
131+
matchers[i].assertTrace(spanLists.get(i), i);
147132
}
148-
return false;
149133
}
150134

151135
private static long earliestStart(List<DecodedSpan> spans) {

0 commit comments

Comments
 (0)