diff --git a/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/AbstractInstrumentationTest.java b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/AbstractInstrumentationTest.java index 17686a7d326..d0b4e5b1b10 100644 --- a/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/AbstractInstrumentationTest.java +++ b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/AbstractInstrumentationTest.java @@ -1,9 +1,12 @@ package datadog.trace.agent.test; +import static java.util.function.Function.identity; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import datadog.instrument.classinject.ClassInjector; +import datadog.trace.agent.test.assertions.TraceAssertions; +import datadog.trace.agent.test.assertions.TraceMatcher; import datadog.trace.agent.tooling.AgentInstaller; import datadog.trace.agent.tooling.InstrumenterModule; import datadog.trace.agent.tooling.TracerInstaller; @@ -22,11 +25,19 @@ import java.util.ServiceLoader; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.function.Function; import net.bytebuddy.agent.ByteBuddyAgent; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; - +import org.opentest4j.AssertionFailedError; + +/** + * This class is an experimental base to run instrumentation tests using JUnit Jupiter. It is still + * early development, and the overall API is expected to change to leverage its extension model. The + * current implementation is inspired and kept close to it Groovy / Spock counterpart, the {@code + * InstrumentationSpecification}. + */ @ExtendWith(TestClassShadowingExtension.class) public abstract class AbstractInstrumentationTest { static final Instrumentation INSTRUMENTATION = ByteBuddyAgent.getInstrumentation(); @@ -100,6 +111,33 @@ public void tearDown() { this.transformerLister = null; } + /** + * Checks the structure of the traces captured from the test tracer. + * + * @param matchers The matchers to verify the trace collection, one matcher by expected trace. + */ + protected void assertTraces(TraceMatcher... matchers) { + assertTraces(identity(), matchers); + } + + /** + * Checks the structure of the traces captured from the test tracer. + * + * @param options The {@link TraceAssertions.Options} to configure the checks. + * @param matchers The matchers to verify the trace collection, one matcher by expected trace. + */ + protected void assertTraces( + Function options, + TraceMatcher... matchers) { + int expectedTraceCount = matchers.length; + try { + this.writer.waitForTraces(expectedTraceCount); + } catch (InterruptedException | TimeoutException e) { + throw new AssertionFailedError("Timeout while waiting for traces", e); + } + TraceAssertions.assertTraces(this.writer, options, matchers); + } + protected void blockUntilChildSpansFinished(final int numberOfSpans) { blockUntilChildSpansFinished(this.tracer.activeSpan(), numberOfSpans); } diff --git a/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/Any.java b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/Any.java new file mode 100644 index 00000000000..4c1c981e07e --- /dev/null +++ b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/Any.java @@ -0,0 +1,30 @@ +package datadog.trace.agent.test.assertions; + +import java.util.Optional; + +/** + * A generic {@link Matcher} implementation that always evaluates to {@code true} for any input. + * + *

This class can be used when any value is acceptable for a match. It is typically used for test + * assertions where no specific value validation is required. + * + * @param the type of the value being matched + */ +public class Any implements Matcher { + Any() {} + + @Override + public Optional expected() { + return Optional.empty(); + } + + @Override + public String failureReason() { + return ""; + } + + @Override + public boolean test(T t) { + return true; + } +} diff --git a/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/Is.java b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/Is.java new file mode 100644 index 00000000000..f142ba1b742 --- /dev/null +++ b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/Is.java @@ -0,0 +1,32 @@ +package datadog.trace.agent.test.assertions; + +import java.util.Optional; + +/** + * A generic {@link Matcher} implementation that verifies if a given value matches the expected + * value. This matcher compares the provided input with a predefined expected value for equality. + * + * @param The type of the value being matched. + */ +public class Is implements Matcher { + private final T expected; + + Is(T expected) { + this.expected = expected; + } + + @Override + public Optional expected() { + return Optional.of(this.expected); + } + + @Override + public String failureReason() { + return "Unexpected value"; + } + + @Override + public boolean test(T t) { + return this.expected.equals(t); + } +} diff --git a/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/IsFalse.java b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/IsFalse.java new file mode 100644 index 00000000000..5e1f2f3f8ab --- /dev/null +++ b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/IsFalse.java @@ -0,0 +1,23 @@ +package datadog.trace.agent.test.assertions; + +import java.util.Optional; + +/** A {@link Matcher} implementation that checks if a given boolean value is {@code false}. */ +public class IsFalse implements Matcher { + IsFalse() {} + + @Override + public Optional expected() { + return Optional.of(false); + } + + @Override + public String failureReason() { + return "False expected"; + } + + @Override + public boolean test(Boolean t) { + return !t; + } +} diff --git a/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/IsNonNull.java b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/IsNonNull.java new file mode 100644 index 00000000000..0e0f37a159e --- /dev/null +++ b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/IsNonNull.java @@ -0,0 +1,27 @@ +package datadog.trace.agent.test.assertions; + +import java.util.Optional; + +/** + * A {@link Matcher} implementation that checks if a given value is not {@code null}. + * + * @param The type of the value being matched. + */ +public class IsNonNull implements Matcher { + IsNonNull() {} + + @Override + public Optional expected() { + return Optional.empty(); + } + + @Override + public String failureReason() { + return "Non-null value expected"; + } + + @Override + public boolean test(T t) { + return t != null; + } +} diff --git a/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/IsNull.java b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/IsNull.java new file mode 100644 index 00000000000..38cf1b07ba2 --- /dev/null +++ b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/IsNull.java @@ -0,0 +1,27 @@ +package datadog.trace.agent.test.assertions; + +import java.util.Optional; + +/** + * A {@link Matcher} implementation that checks if a given value is {@code null}. + * + * @param The type of the value being matched. + */ +public class IsNull implements Matcher { + IsNull() {} + + @Override + public Optional expected() { + return Optional.empty(); + } + + @Override + public String failureReason() { + return "Null value expected"; + } + + @Override + public boolean test(T t) { + return t == null; + } +} diff --git a/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/IsTrue.java b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/IsTrue.java new file mode 100644 index 00000000000..b7529bc697a --- /dev/null +++ b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/IsTrue.java @@ -0,0 +1,23 @@ +package datadog.trace.agent.test.assertions; + +import java.util.Optional; + +/** A {@link Matcher} implementation that checks if a given boolean value is {@code true}. */ +public class IsTrue implements Matcher { + IsTrue() {} + + @Override + public Optional expected() { + return Optional.of(true); + } + + @Override + public String failureReason() { + return "True expected"; + } + + @Override + public boolean test(Boolean t) { + return t; + } +} diff --git a/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/Matcher.java b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/Matcher.java new file mode 100644 index 00000000000..facbc29f5ba --- /dev/null +++ b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/Matcher.java @@ -0,0 +1,27 @@ +package datadog.trace.agent.test.assertions; + +import java.util.Optional; +import java.util.function.Predicate; + +/** + * This interface represents a generic matcher to evaluate whether a given value matches certain + * criteria defined by the implementation. + * + * @param the type of the value being matched. + */ +public interface Matcher extends Predicate { + /** + * Gets the expected value for this matcher, if any. + * + * @return The expected value wrapped into an {@link Optional}, or {@link Optional#empty()} if no + * specific value is expected. + */ + Optional expected(); + + /** + * Explains the reason why the value does not match the expectation. + * + * @return The failure reason. + */ + String failureReason(); +} diff --git a/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/Matchers.java b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/Matchers.java new file mode 100644 index 00000000000..cf25a5fa581 --- /dev/null +++ b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/Matchers.java @@ -0,0 +1,114 @@ +package datadog.trace.agent.test.assertions; + +import java.util.Optional; +import java.util.function.Predicate; +import java.util.regex.Pattern; +import org.opentest4j.AssertionFailedError; + +/** This class is a utility class to create generic matchers. */ +public final class Matchers { + private Matchers() {} + + /** + * Creates a matcher that checks if the provided value is equal to the expected value. + * + * @param The type of the value being matched. + * @param expected The value to compare against + * @return A {@link Matcher} that verifies equality with the expected value. + */ + public static Matcher is(T expected) { + return new Is<>(expected); + } + + /** + * Creates a matcher that checks if a given value is {@code null}. + * + * @param The type of the value being matched. + * @return A {@link Matcher} that verifies the value is {@code null}. + */ + public static Matcher isNull() { + return new IsNull<>(); + } + + /** + * Creates a matcher that checks if a given value is not {@code null}. + * + * @param The type of the value being matched. + * @return A {@link Matcher} that verifies the value is not {@code null}. + */ + public static Matcher isNonNull() { + return new IsNonNull<>(); + } + + /** + * Creates a matcher that checks if a given boolean value is {@code true}. + * + * @return A {@link Matcher} that verifies the value is {@code true}. + */ + public static Matcher isTrue() { + return new IsTrue(); + } + + /** + * Creates a matcher that checks if a given boolean value is {@code false}. + * + * @return A {@link Matcher} that verifies the value is {@code false}. + */ + public static Matcher isFalse() { + return new IsFalse(); + } + + /** + * Creates a {@link Matcher} that checks if a given string matches a specified regular expression. + * + * @param regex The regular expression pattern used for matching. + * @return A {@link Matcher} that validates if a string matches the provided regular expression. + */ + public static Matcher matches(String regex) { + return new Matches(Pattern.compile(regex)); + } + + /** + * Creates a {@link Matcher} that checks if a given string matches a specified {@link Pattern}. + * + * @param pattern The regular expression pattern used for matching. + * @return A {@link Matcher} that validates if a string matches the provided {@link Pattern}. + */ + public static Matcher matches(Pattern pattern) { + return new Matches(pattern); + } + + /** + * Creates a {@link Matcher} that validates a given value based on the provided {@link Predicate}. + * This method allows specifying custom validation logic for matching input values. + * + * @param The type of the value being validated. + * @param validator A {@link Predicate} representing the custom validation logic to be applied. + * @return A {@link Matcher} that uses the provided {@link Predicate} to validate input values. + */ + public static Matcher validates(Predicate validator) { + return new Validates<>(validator); + } + + /** + * Creates a matcher that always accepts any input value. + * + * @param The type of the value being matched. + * @return A {@link Matcher} that accepts any value and always matches. + */ + public static Matcher any() { + return new Any<>(); + } + + static void assertValue(Matcher matcher, T value, String message) { + if (matcher != null && !matcher.test(value)) { + Optional expected = matcher.expected(); + if (expected.isPresent()) { + throw new AssertionFailedError( + message + ". " + matcher.failureReason(), expected.get(), value); + } else { + throw new AssertionFailedError(message + ": " + value + ". " + matcher.failureReason()); + } + } + } +} diff --git a/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/Matches.java b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/Matches.java new file mode 100644 index 00000000000..edc71e446a8 --- /dev/null +++ b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/Matches.java @@ -0,0 +1,32 @@ +package datadog.trace.agent.test.assertions; + +import java.util.Optional; +import java.util.regex.Pattern; + +/** + * A matcher implementation that checks if strings (as {@link CharSequence}) match a specified + * {@link Pattern}. This class is used for validating whether a string conforms to a specific + * regular expression. + */ +public class Matches implements Matcher { + private final Pattern pattern; + + Matches(Pattern pattern) { + this.pattern = pattern; + } + + @Override + public Optional expected() { + return Optional.empty(); + } + + @Override + public String failureReason() { + return "Non matching value"; + } + + @Override + public boolean test(CharSequence s) { + return this.pattern.matcher(s).matches(); + } +} diff --git a/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/SpanLinkMatcher.java b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/SpanLinkMatcher.java new file mode 100644 index 00000000000..ef70ace9764 --- /dev/null +++ b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/SpanLinkMatcher.java @@ -0,0 +1,116 @@ +package datadog.trace.agent.test.assertions; + +import static datadog.trace.agent.test.assertions.Matchers.assertValue; +import static datadog.trace.agent.test.assertions.Matchers.is; +import static datadog.trace.bootstrap.instrumentation.api.AgentSpanLink.DEFAULT_FLAGS; +import static datadog.trace.bootstrap.instrumentation.api.SpanAttributes.EMPTY; + +import datadog.trace.api.DDTraceId; +import datadog.trace.bootstrap.instrumentation.api.AgentSpanContext; +import datadog.trace.bootstrap.instrumentation.api.AgentSpanLink; +import datadog.trace.bootstrap.instrumentation.api.SpanAttributes; +import datadog.trace.core.DDSpan; + +/** + * Provides matchers for span links based on their properties such as trace and span IDs links refer + * to, trace flags, span attributes, and trace state. + */ +public final class SpanLinkMatcher { + private final Matcher traceIdMatcher; + private final Matcher spanIdMatcher; + private Matcher traceFlagsMatcher; + private Matcher spanAttributesMatcher; + private Matcher traceStateMatcher; + + private SpanLinkMatcher(Matcher traceIdMatcher, Matcher spanIdMatcher) { + this.traceIdMatcher = traceIdMatcher; + this.spanIdMatcher = spanIdMatcher; + this.traceFlagsMatcher = is(DEFAULT_FLAGS); + this.spanAttributesMatcher = is(EMPTY); + this.traceStateMatcher = is(""); + } + + /** + * Creates a {@code SpanLinkMatcher} that matches a span link to the given span. + * + * @param span The span the link should match to. + * @return A {@code SpanLinkMatcher} that matches a span link to the given span. + */ + public static SpanLinkMatcher to(DDSpan span) { + return to(span.context()); + } + + /** + * Creates a {@code SpanLinkMatcher} that matches a span link to the given span context. + * + * @param spanContext The span context the span link should match to. + * @return A {@code SpanLinkMatcher} that matches a span link to the given span context. + */ + public static SpanLinkMatcher to(AgentSpanContext spanContext) { + return to(spanContext.getTraceId(), spanContext.getSpanId()); + } + + /** + * Creates a {@code SpanLinkMatcher} that matches a span link to the given trace / span + * identifiers. + * + * @param traceId The trace ID the span link should match to. + * @param spanId The trace ID the span link should match to. + * @return A {@code SpanLinkMatcher} that matches a span link to the given trace / span + * identifiers. + */ + public static SpanLinkMatcher to(DDTraceId traceId, long spanId) { + return new SpanLinkMatcher(is(traceId), is(spanId)); + } + + /** + * Creates a {@code SpanLinkMatcher} that matches any span link. + * + * @return A {@code SpanLinkMatcher} that matches any span link. + */ + public static SpanLinkMatcher any() { + return new SpanLinkMatcher(Matchers.any(), Matchers.any()); + } + + /** + * Sets the trace flags value to match against. + * + * @param traceFlags The byte value representing the trace flags to match against. + * @return The updated {@code SpanLinkMatcher} instance with the new trace flags constraint. + */ + public SpanLinkMatcher traceFlags(byte traceFlags) { + this.traceFlagsMatcher = is(traceFlags); + return this; + } + + /** + * Sets the span attributes value to match against. + * + * @param spanAttributes The span attributes to match against. + * @return The updated {@code SpanLinkMatcher} instance with the new span attributes constraint. + */ + public SpanLinkMatcher attributes(SpanAttributes spanAttributes) { + this.spanAttributesMatcher = is(spanAttributes); + return this; + } + + /** + * Sets the trace state value to match against. + * + * @param traceState The trace state to match against. + * @return The updated {@code SpanLinkMatcher} instance with the new trace state constraint. + */ + public SpanLinkMatcher traceState(String traceState) { + this.traceStateMatcher = is(traceState); + return this; + } + + void assertLink(AgentSpanLink link) { + // Assert link values + assertValue(this.traceIdMatcher, link.traceId(), "Expected trace identifier"); + assertValue(this.spanIdMatcher, link.spanId(), "Expected span identifier"); + assertValue(this.traceFlagsMatcher, link.traceFlags(), "Expected trace flags"); + assertValue(this.spanAttributesMatcher, link.attributes(), "Expected attributes"); + assertValue(this.traceStateMatcher, link.traceState(), "Expected trace state"); + } +} diff --git a/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/SpanMatcher.java b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/SpanMatcher.java new file mode 100644 index 00000000000..4ceb959c797 --- /dev/null +++ b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/SpanMatcher.java @@ -0,0 +1,358 @@ +package datadog.trace.agent.test.assertions; + +import static datadog.trace.agent.test.assertions.Matchers.assertValue; +import static datadog.trace.agent.test.assertions.Matchers.is; +import static datadog.trace.agent.test.assertions.Matchers.isFalse; +import static datadog.trace.agent.test.assertions.Matchers.isNonNull; +import static datadog.trace.agent.test.assertions.Matchers.isNull; +import static datadog.trace.agent.test.assertions.Matchers.isTrue; +import static datadog.trace.agent.test.assertions.Matchers.matches; +import static datadog.trace.agent.test.assertions.Matchers.validates; +import static datadog.trace.core.DDSpanAccessor.spanLinks; +import static java.time.Duration.ofNanos; + +import datadog.trace.api.TagMap; +import datadog.trace.bootstrap.instrumentation.api.AgentSpanLink; +import datadog.trace.core.DDSpan; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; +import java.util.regex.Pattern; +import org.opentest4j.AssertionFailedError; + +/** + * The class is a helper class to verify span attributes. + * + *

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

Span matching constraints includes: + * + *

+ */ +public final class SpanMatcher { + private Matcher idMatcher; + private Matcher parentIdMatcher; + private Matcher serviceNameMatcher; + private Matcher operationNameMatcher; + private Matcher resourceNameMatcher; + private Matcher durationMatcher; + private Matcher typeMatcher; + private Matcher errorMatcher; + private TagsMatcher[] tagMatchers; + private SpanLinkMatcher[] linkMatchers; + + private static final Matcher CHILD_OF_PREVIOUS_MATCHER = is(0L); + + private SpanMatcher() { + this.serviceNameMatcher = validates(s -> s != null && !s.isEmpty()); + this.typeMatcher = isNull(); + this.errorMatcher = isFalse(); + } + + /** + * 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 identifier matches the given value. + * + * @param id The identifier of the span to match against. + * @return The current {@link SpanMatcher} instance with the specified identifier constraint + * applied. + */ + public SpanMatcher id(long id) { + this.idMatcher = is(id); + 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() { + return childOf(0L); + } + + /** + * Checks the span is a direct child of the specified parent span. + * + * @param parentId 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 parentId) { + this.parentIdMatcher = is(parentId); + 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; + return this; + } + + /** + * Checks the span has service name defined. + * + * @return The current {@link SpanMatcher} instance with a defined service name constraint + * applied. + */ + public SpanMatcher serviceNameDefined() { + this.serviceNameMatcher = isNonNull(); + return this; + } + + /** + * Checks the span service name matches the given value. + * + * @param serviceName The service name to match against. + * @return The current {@link SpanMatcher} instance updated with the specified service name + * constraint. + */ + public SpanMatcher serviceName(String serviceName) { + this.serviceNameMatcher = is(serviceName); + 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 duration is shorter than the given value. + * + * @param duration The maximum allowed duration. + * @return The current {@link SpanMatcher} instance updated with the specified duration + * constraint. + */ + public SpanMatcher durationShorterThan(Duration duration) { + this.durationMatcher = validates(d -> d.compareTo(duration) < 0); + return this; + } + + /** + * Checks the span duration is longer than the given value. + * + * @param duration The minimum allowed duration. + * @return The current {@link SpanMatcher} instance updated with the specified duration + * constraint. + */ + public SpanMatcher durationLongerThan(Duration duration) { + this.durationMatcher = validates(d -> d.compareTo(duration) > 0); + return this; + } + + /** + * Checks the span duration matches the given validator. + * + * @param validator The validator to check the span duration. + * @return The current {@link SpanMatcher} instance updated with the specified duration + * constraint. + */ + public SpanMatcher duration(Predicate validator) { + this.durationMatcher = 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 is an error span. + * + * @return The current {@link SpanMatcher} instance updated with the specified error constraint. + */ + public SpanMatcher error() { + return error(true); + } + + /** + * 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; + } + + public SpanMatcher tags(TagsMatcher... matchers) { + this.tagMatchers = matchers; + return this; + } + + /** + * Checks the span links structure. + * + * @param matchers The {@link SpanLinkMatcher} to very the span links structure, one per link. + * @return The current {@link SpanMatcher} instance updated with the specified span link + * constraints. + */ + public SpanMatcher links(SpanLinkMatcher... matchers) { + this.linkMatchers = matchers; + return this; + } + + void assertSpan(DDSpan span, DDSpan previousSpan) { + // Apply parent id matcher from the previous span + if (this.parentIdMatcher == CHILD_OF_PREVIOUS_MATCHER) { + this.parentIdMatcher = is(previousSpan.getSpanId()); + } + // Assert span values + assertValue(this.idMatcher, span.getSpanId(), "Expected identifier"); + assertValue(this.parentIdMatcher, span.getParentId(), "Expected parent identifier"); + assertValue(this.serviceNameMatcher, span.getServiceName(), "Expected service name"); + assertValue(this.operationNameMatcher, span.getOperationName(), "Expected operation name"); + assertValue(this.resourceNameMatcher, span.getResourceName(), "Expected resource name"); + assertValue(this.durationMatcher, ofNanos(span.getDurationNano()), "Expected duration"); + assertValue(this.typeMatcher, span.getSpanType(), "Expected span type"); + assertValue(this.errorMatcher, span.isError(), "Expected error status"); + assertSpanTags(span.getTags()); + assertSpanLinks(spanLinks(span)); + } + + private void assertSpanTags(TagMap tags) { + // Check if tags should be asserted at all + if (this.tagMatchers == null) { + return; + } + // Collect all matchers + Map> matchers = new HashMap<>(); + for (TagsMatcher tagMatcher : this.tagMatchers) { + matchers.putAll(tagMatcher.tagMatchers); + } + // Assert all tags + List uncheckedTagNames = new ArrayList<>(); + tags.forEach( + (key, value) -> { + Matcher matcher = (Matcher) matchers.remove(key); + if (matcher == null) { + uncheckedTagNames.add(key); + } else { + assertValue(matcher, value, "Unexpected " + key + " tag value."); + } + }); + // Remove matchers that accept missing tags + Collection> values = matchers.values(); + values.removeIf(matcher -> matcher instanceof Any); + values.removeIf(matcher -> matcher instanceof IsNull); + // Fails if any tags are missing + if (!matchers.isEmpty()) { + throw new AssertionFailedError("Missing tags: " + String.join(", ", matchers.keySet())); + } + // Fails if any unexpected tags are present + if (!uncheckedTagNames.isEmpty()) { + throw new AssertionFailedError("Unexpected tags: " + String.join(", ", uncheckedTagNames)); + } + } + + /* + * Right now, it's expecting to have as many matchers as links, and in the same order. + * It might evolve into partial link collection testing, matching links using TID/SIP. + */ + private void assertSpanLinks(List links) { + int linkCount = links == null ? 0 : links.size(); + int expectedLinkCount = this.linkMatchers == null ? 0 : this.linkMatchers.length; + if (linkCount != expectedLinkCount) { + throw new AssertionFailedError("Unexpected span link count", expectedLinkCount, linkCount); + } + for (int i = 0; i < expectedLinkCount; i++) { + SpanLinkMatcher linkMatcher = this.linkMatchers[expectedLinkCount]; + AgentSpanLink link = links.get(i); + linkMatcher.assertLink(link); + } + } +} diff --git a/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/TagsMatcher.java b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/TagsMatcher.java new file mode 100644 index 00000000000..21dae20ebaa --- /dev/null +++ b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/TagsMatcher.java @@ -0,0 +1,134 @@ +package datadog.trace.agent.test.assertions; + +import static datadog.trace.agent.test.assertions.Matchers.any; +import static datadog.trace.agent.test.assertions.Matchers.is; +import static datadog.trace.agent.test.assertions.Matchers.isNonNull; +import static datadog.trace.api.DDTags.ERROR_MSG; +import static datadog.trace.api.DDTags.ERROR_STACK; +import static datadog.trace.api.DDTags.ERROR_TYPE; +import static datadog.trace.api.DDTags.LANGUAGE_TAG_KEY; +import static datadog.trace.api.DDTags.REQUIRED_CODE_ORIGIN_TAGS; +import static datadog.trace.api.DDTags.RUNTIME_ID_TAG; +import static datadog.trace.api.DDTags.THREAD_ID; +import static datadog.trace.api.DDTags.THREAD_NAME; +import static datadog.trace.common.sampling.RateByServiceTraceSampler.SAMPLING_AGENT_RATE; +import static datadog.trace.common.writer.ddagent.TraceMapper.SAMPLING_PRIORITY_KEY; + +import datadog.trace.api.DDTags; +import java.util.HashMap; +import java.util.Map; + +public final class TagsMatcher { + final Map> tagMatchers; + + private TagsMatcher(Map> tagMatchers) { + this.tagMatchers = tagMatchers; + } + + public static TagsMatcher defaultTags() { + Map> tagMatchers = new HashMap<>(); + tagMatchers.put(THREAD_NAME, isNonNull()); + tagMatchers.put(THREAD_ID, isNonNull()); + tagMatchers.put(RUNTIME_ID_TAG, any()); + tagMatchers.put(LANGUAGE_TAG_KEY, any()); + tagMatchers.put(SAMPLING_AGENT_RATE, any()); + tagMatchers.put(SAMPLING_PRIORITY_KEY.toString(), any()); + tagMatchers.put("_sample_rate", any()); + tagMatchers.put(DDTags.PID_TAG, any()); + tagMatchers.put(DDTags.SCHEMA_VERSION_TAG_KEY, any()); + tagMatchers.put(DDTags.PROFILING_ENABLED, any()); + tagMatchers.put(DDTags.PROFILING_CONTEXT_ENGINE, any()); + tagMatchers.put(DDTags.BASE_SERVICE, any()); + tagMatchers.put(DDTags.DSM_ENABLED, any()); + tagMatchers.put(DDTags.DJM_ENABLED, any()); + tagMatchers.put(DDTags.PARENT_ID, any()); + tagMatchers.put(DDTags.SPAN_LINKS, any()); // this is checked by LinksAsserter + + for (String tagName : REQUIRED_CODE_ORIGIN_TAGS) { + tagMatchers.put(tagName, any()); + } + // TODO Keep porting default tag logic + // TODO Dev notes: + // - it seems there is way too many logic there + // - need to check if its related to tracing only + + return new TagsMatcher(tagMatchers); + } + + /** + * Requires the following tag to match the given matcher. + * + * @param tagName The tag name to match. + * @param matcher The matcher to apply to the tag value. + * @return A tag matcher that requires the following tag to match the given matcher. + */ + public static TagsMatcher tag(String tagName, Matcher matcher) { + Map> tagMatchers = new HashMap<>(); + tagMatchers.put(tagName, matcher); + return new TagsMatcher(tagMatchers); + } + + /** + * Requires the following tags to be present. + * + * @param tagNames The tag names to match. + * @return A tag matcher that requires the following tags to be present. + */ + public static TagsMatcher includes(String... tagNames) { + Map> tagMatchers = new HashMap<>(); + for (String tagName : tagNames) { + tagMatchers.put(tagName, any()); + } + return new TagsMatcher(tagMatchers); + } + + /** + * Requires the error tags to match the given error. + * + * @param error The error to match. + * @return A tag matcher that requires the error tags to match the given error. + */ + public static TagsMatcher error(Throwable error) { + return error(error.getClass(), error.getMessage()); + } + + /** + * Requires the error tags to match the given error type. + * + * @param errorType The error type to match. + * @return A tag matcher that requires the error tags to match the given error type. + */ + public static TagsMatcher error(Class errorType) { + return error(errorType, null); + } + + /** + * Requires the error tags to match the given error type and message. + * + * @param errorType The error type to match. + * @param message The error message to match. + * @return A tag matcher that requires the error tags to match the given error type and message. + */ + public static TagsMatcher error(Class errorType, String message) { + Map> tagMatchers = new HashMap<>(); + tagMatchers.put(ERROR_TYPE, Matchers.validates(s -> testErrorType(errorType, s))); + tagMatchers.put(ERROR_STACK, isNonNull()); + if (message != null) { + tagMatchers.put(ERROR_MSG, is(message)); + } + return new TagsMatcher(tagMatchers); + } + + static boolean testErrorType(Class errorType, String actual) { + if (errorType.getName().equals(actual)) { + return true; + } + try { + // also accept type names which are subclasses of the given error type + return errorType.isAssignableFrom( + Class.forName(actual, false, TagsMatcher.class.getClassLoader())); + } catch (Throwable ignore) { + return false; + } + } +} diff --git a/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/TraceAssertions.java b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/TraceAssertions.java new file mode 100644 index 00000000000..58726369ba3 --- /dev/null +++ b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/TraceAssertions.java @@ -0,0 +1,120 @@ +package datadog.trace.agent.test.assertions; + +import static java.util.function.Function.identity; + +import datadog.trace.core.DDSpan; +import java.util.Comparator; +import java.util.List; +import java.util.function.Function; +import org.opentest4j.AssertionFailedError; + +/** + * This class is a helper class to verify traces structure. + * + *

To check for traces structure, use the static factory methods: {@link #assertTraces(List, + * TraceMatcher...)} with the expected {@link TraceMatcher}s (one per trace), or {@link + * #assertTraces(List, Function, 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 #SORT_BY_ROOT_SPAN_ID} sorts traces by their root span identifiers. + *
+ */ +public final class TraceAssertions { + /** Trace comparator to sort by start time. */ + public static final Comparator> TRACE_START_TIME_COMPARATOR = + Comparator.comparingLong( + trace -> trace.isEmpty() ? 0L : trace.get(0).getLocalRootSpan().getStartTime()); + + /** Trace comparator to sort by root span identifier. */ + public static final Comparator> TRACE_ROOT_SPAN_ID_COMPARATOR = + Comparator.comparingLong( + trace -> trace.isEmpty() ? 0L : trace.get(0).getLocalRootSpan().getSpanId()); + + /* + * Trace assertions options. + */ + /** Ignores addition traces. If there are more traces than expected, do not fail. */ + public static final Function IGNORE_ADDITIONAL_TRACES = + Options::ignoredAdditionalTraces; + + /** Sorts traces by start time. */ + public static final Function SORT_BY_START_TIME = + options -> options.sorter(TRACE_START_TIME_COMPARATOR); + + /** Sorts traces by their root span identifier. */ + public static final Function SORT_BY_ROOT_SPAN_ID = + options -> options.sorter(TRACE_ROOT_SPAN_ID_COMPARATOR); + + private TraceAssertions() {} + + /** + * Checks a trace structure. + * + * @param trace The trace to check. + * @param matcher The matcher to verify the trace structure. + */ + public static void assertTrace(List trace, TraceMatcher matcher) { + matcher.assertTrace(trace, 0); + } + + /** + * 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, Function options, TraceMatcher... matchers) { + Options opts = options.apply(new Options()); + int expectedTraceCount = matchers.length; + int traceCount = traces.size(); + if (opts.ignoredAdditionalTraces) { + if (traceCount < expectedTraceCount) { + throw new AssertionFailedError("Not enough of traces", expectedTraceCount, traceCount); + } + } else { + if (traceCount != expectedTraceCount) { + throw new AssertionFailedError("Invalid number of traces", expectedTraceCount, traceCount); + } + } + if (opts.sorter != null) { + traces.sort(opts.sorter); + } + for (int i = 0; i < expectedTraceCount; i++) { + List trace = traces.get(i); + matchers[i].assertTrace(trace, i); + } + } + + public static class Options { + boolean ignoredAdditionalTraces = false; + Comparator> sorter = TRACE_START_TIME_COMPARATOR; + + public Options ignoredAdditionalTraces() { + this.ignoredAdditionalTraces = true; + return this; + } + + public Options sorter(Comparator> sorter) { + this.sorter = sorter; + return this; + } + } +} diff --git a/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/TraceMatcher.java b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/TraceMatcher.java new file mode 100644 index 00000000000..21b6e013b30 --- /dev/null +++ b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/TraceMatcher.java @@ -0,0 +1,85 @@ +package datadog.trace.agent.test.assertions; + +import datadog.trace.core.DDSpan; +import java.util.Comparator; +import java.util.List; +import java.util.function.Function; +import org.opentest4j.AssertionFailedError; + +/** + * This class is a helper class to verify a trace structure. + * + *

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

{@link #SORT_BY_START_TIME} can be used as predefined configuration to sort spans by start + * time. + * + * @see TraceAssertions + * @see SpanMatcher + */ +public final class TraceMatcher { + public static final Comparator START_TIME_COMPARATOR = + Comparator.comparingLong(DDSpan::getStartTime); + public static Function SORT_BY_START_TIME = + options -> options.sorter(START_TIME_COMPARATOR); + + private final Options options; + private final SpanMatcher[] matchers; + + private TraceMatcher(Options options, SpanMatcher[] matchers) { + if (matchers.length == 0) { + throw new IllegalArgumentException("No span matchers provided"); + } + this.options = options; + this.matchers = matchers; + } + + /** + * 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 TraceAssertions.Options} to configure the checks. + * @param matchers The matchers to verify the trace structure. + */ + public static TraceMatcher trace(Function options, SpanMatcher... matchers) { + return new TraceMatcher(options.apply(new Options()), matchers); + } + + void assertTrace(List trace, int traceIndex) { + int spanCount = trace.size(); + if (spanCount != this.matchers.length) { + throw new AssertionFailedError( + "Invalid number of spans for trace " + traceIndex + " : " + trace, + this.matchers.length, + spanCount); + } + if (this.options.sorter != null) { + trace.sort(this.options.sorter); + } + DDSpan previousSpan = null; + for (int i = 0; i < spanCount; i++) { + DDSpan span = trace.get(i); + this.matchers[i].assertSpan(span, previousSpan); + previousSpan = span; + } + } + + public static class Options { + Comparator sorter = null; + + public Options sorter(Comparator sorter) { + this.sorter = sorter; + return this; + } + } +} diff --git a/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/Validates.java b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/Validates.java new file mode 100644 index 00000000000..234bfde7a63 --- /dev/null +++ b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/Validates.java @@ -0,0 +1,34 @@ +package datadog.trace.agent.test.assertions; + +import java.util.Optional; +import java.util.function.Predicate; + +/** + * A {@link Matcher} implementation that validates a given input value using a custom {@link + * Predicate}. This class allows defining flexible matching criteria by providing a lambda or + * functional interface that encapsulates the validation logic. + * + * @param The type of the value being validated. + */ +public class Validates implements Matcher { + private final Predicate validator; + + Validates(Predicate validator) { + this.validator = validator; + } + + @Override + public Optional expected() { + return Optional.empty(); + } + + @Override + public String failureReason() { + return "Invalid value"; + } + + @Override + public boolean test(T t) { + return this.validator.test(t); + } +} diff --git a/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/package-info.java b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/package-info.java new file mode 100644 index 00000000000..3636497d198 --- /dev/null +++ b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/package-info.java @@ -0,0 +1,20 @@ +/** + * Dev notes + * + *

Constraints: + * + *

    + *
  • Introduce as few as possible matchers + *
  • Only have matchers for generic purpose, don't introduce feature / produce / use-case + * specific matchers + *
+ * + * Todo: + * + *
    + *
  • Think about extensibility? Open matchers for inheritance or specialization + *
  • Tag assertions are WIP. Too much coupling in the current Groovy solution + *
  • Span links assertions might be a bit too rigid for now + *
+ */ +package datadog.trace.agent.test.assertions; diff --git a/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/core/DDSpanAccessor.java b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/core/DDSpanAccessor.java new file mode 100644 index 00000000000..3e88be78e4d --- /dev/null +++ b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/core/DDSpanAccessor.java @@ -0,0 +1,16 @@ +package datadog.trace.core; + +import datadog.trace.bootstrap.instrumentation.api.AgentSpanLink; +import java.util.List; + +/** + * This class is a helper class to get access to package private span data that should not be + * exposed as part of the public API. + */ +public final class DDSpanAccessor { + private DDSpanAccessor() {} + + public static List spanLinks(DDSpan span) { + return span.links; + } +} diff --git a/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/test/java/testdog/trace/instrumentation/java/lang/jdk21/VirtualThreadApiInstrumentationTest.java b/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/test/java/testdog/trace/instrumentation/java/lang/jdk21/VirtualThreadApiInstrumentationTest.java index f702400a7a7..e0bfd5b9088 100644 --- a/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/test/java/testdog/trace/instrumentation/java/lang/jdk21/VirtualThreadApiInstrumentationTest.java +++ b/dd-java-agent/instrumentation/java/java-lang/java-lang-21.0/src/test/java/testdog/trace/instrumentation/java/lang/jdk21/VirtualThreadApiInstrumentationTest.java @@ -1,14 +1,11 @@ package testdog.trace.instrumentation.java.lang.jdk21; -import static java.util.Collections.emptyList; -import static java.util.Comparator.comparing; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.fail; +import static datadog.trace.agent.test.assertions.SpanMatcher.span; +import static datadog.trace.agent.test.assertions.TraceMatcher.SORT_BY_START_TIME; +import static datadog.trace.agent.test.assertions.TraceMatcher.trace; import datadog.trace.agent.test.AbstractInstrumentationTest; import datadog.trace.api.Trace; -import datadog.trace.core.DDSpan; -import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeoutException; @@ -135,36 +132,20 @@ public void run() { latch.await(); - var trace = getTrace(); - trace.sort(comparing(DDSpan::getStartTimeNano)); - assertEquals(4, trace.size()); - assertEquals("parent", trace.get(0).getOperationName()); - assertEquals("child", trace.get(1).getOperationName()); - assertEquals("great-child", trace.get(2).getOperationName()); - assertEquals("great-great-child", trace.get(3).getOperationName()); - assertEquals(trace.get(0).getSpanId(), trace.get(1).getParentId()); - assertEquals(trace.get(1).getSpanId(), trace.get(2).getParentId()); - assertEquals(trace.get(2).getSpanId(), trace.get(3).getParentId()); + assertTraces( + trace( + SORT_BY_START_TIME, + span().root().operationName("parent"), + span().childOfPrevious().operationName("child"), + span().childOfPrevious().operationName("great-child"), + span().childOfPrevious().operationName("great-great-child"))); } /** Verifies the parent / child span relation. */ void assertConnectedTrace() { - var trace = getTrace(); - trace.sort(comparing(DDSpan::getStartTimeNano)); - assertEquals(2, trace.size()); - assertEquals("parent", trace.get(0).getOperationName()); - assertEquals("asyncChild", trace.get(1).getOperationName()); - assertEquals(trace.get(0).getSpanId(), trace.get(1).getParentId()); - } - - List getTrace() { - try { - writer.waitForTraces(1); - assertEquals(1, writer.size()); - return writer.getFirst(); - } catch (InterruptedException | TimeoutException e) { - fail("Failed to wait for trace to finish.", e); - return emptyList(); - } + assertTraces( + trace( + span().root().operationName("parent"), + span().childOfPrevious().operationName("asyncChild"))); } }