diff --git a/dd-java-agent/instrumentation/spark/sparkjava-2.3/build.gradle b/dd-java-agent/instrumentation/spark/sparkjava-2.3/build.gradle
index d2c1dabe2a2..14ce833d991 100644
--- a/dd-java-agent/instrumentation/spark/sparkjava-2.3/build.gradle
+++ b/dd-java-agent/instrumentation/spark/sparkjava-2.3/build.gradle
@@ -1,4 +1,3 @@
-
// building against 2.3 and testing against 2.4 because JettyHandler is available since 2.4 only
muzzle {
pass {
@@ -22,3 +21,7 @@ dependencies {
latestDepTestImplementation group: 'com.sparkjava', name: 'spark-core', version: '+'
}
+
+tasks.withType(Test).configureEach {
+ jvmArgs += ['-Ddd.trace.enabled=true']
+}
diff --git a/dd-java-agent/instrumentation/spark/sparkjava-2.3/src/main/java/datadog/trace/instrumentation/sparkjava/RoutesInstrumentation.java b/dd-java-agent/instrumentation/spark/sparkjava-2.3/src/main/java/datadog/trace/instrumentation/sparkjava/RoutesInstrumentation.java
index b4dbe6e5c02..747106fecec 100644
--- a/dd-java-agent/instrumentation/spark/sparkjava-2.3/src/main/java/datadog/trace/instrumentation/sparkjava/RoutesInstrumentation.java
+++ b/dd-java-agent/instrumentation/spark/sparkjava-2.3/src/main/java/datadog/trace/instrumentation/sparkjava/RoutesInstrumentation.java
@@ -11,6 +11,7 @@
import datadog.trace.agent.tooling.Instrumenter;
import datadog.trace.agent.tooling.InstrumenterModule;
import datadog.trace.bootstrap.instrumentation.api.AgentSpan;
+import datadog.trace.bootstrap.instrumentation.api.Tags;
import net.bytebuddy.asm.Advice;
import spark.route.HttpMethod;
import spark.routematch.RouteMatch;
@@ -24,8 +25,8 @@ public RoutesInstrumentation() {
}
@Override
- public boolean defaultEnabled() {
- return false;
+ public String[] helperClassNames() {
+ return new String[] {packageName + ".SparkJavaNaming"};
}
@Override
@@ -52,6 +53,8 @@ public static void routeMatchEnricher(
final AgentSpan span = activeSpan();
if (span != null && routeMatch != null) {
HTTP_RESOURCE_DECORATOR.withRoute(span, method.name(), routeMatch.getMatchUri());
+ span.setSpanName(SparkJavaNaming.SPARK_REQUEST);
+ span.setTag(Tags.COMPONENT, SparkJavaNaming.SPARK_JAVA);
}
}
}
diff --git a/dd-java-agent/instrumentation/spark/sparkjava-2.3/src/main/java/datadog/trace/instrumentation/sparkjava/SparkJavaNaming.java b/dd-java-agent/instrumentation/spark/sparkjava-2.3/src/main/java/datadog/trace/instrumentation/sparkjava/SparkJavaNaming.java
new file mode 100644
index 00000000000..878d4b6e5bd
--- /dev/null
+++ b/dd-java-agent/instrumentation/spark/sparkjava-2.3/src/main/java/datadog/trace/instrumentation/sparkjava/SparkJavaNaming.java
@@ -0,0 +1,11 @@
+package datadog.trace.instrumentation.sparkjava;
+
+import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString;
+
+public final class SparkJavaNaming {
+
+ public static final CharSequence SPARK_JAVA = UTF8BytesString.create("spark-java");
+ public static final CharSequence SPARK_REQUEST = UTF8BytesString.create("spark.request");
+
+ private SparkJavaNaming() {}
+}
diff --git a/dd-java-agent/instrumentation/spark/sparkjava-2.3/src/test/groovy/SparkJavaBasedTest.groovy b/dd-java-agent/instrumentation/spark/sparkjava-2.3/src/test/groovy/SparkJavaBasedTest.groovy
deleted file mode 100644
index 2c33e8d745b..00000000000
--- a/dd-java-agent/instrumentation/spark/sparkjava-2.3/src/test/groovy/SparkJavaBasedTest.groovy
+++ /dev/null
@@ -1,73 +0,0 @@
-import datadog.trace.agent.test.InstrumentationSpecification
-import datadog.trace.agent.test.utils.OkHttpUtils
-import datadog.trace.agent.test.utils.PortUtils
-import datadog.trace.api.DDSpanTypes
-import datadog.trace.bootstrap.instrumentation.api.Tags
-import okhttp3.OkHttpClient
-import okhttp3.Request
-import spark.Spark
-import spock.lang.Shared
-
-class SparkJavaBasedTest extends InstrumentationSpecification {
-
- @Override
- void configurePreAgent() {
- super.configurePreAgent()
- injectSysConfig("dd.integration.jetty.enabled", "true")
- injectSysConfig("dd.integration.sparkjava.enabled", "true")
- }
-
- @Shared
- int port
-
- OkHttpClient client = OkHttpUtils.client()
-
- def setupSpec() {
- port = PortUtils.randomOpenPort()
- TestSparkJavaApplication.initSpark(port)
- }
-
- def cleanupSpec() {
- Spark.stop()
- }
-
- def "generates spans"() {
- setup:
- def request = new Request.Builder()
- .url("http://localhost:$port/param/asdf1234")
- .get()
- .build()
- def response = client.newCall(request).execute()
-
- expect:
- port != 0
- response.body().string() == "Hello asdf1234"
-
- assertTraces(1) {
- trace(1) {
- span {
- operationName "servlet.request"
- resourceName "GET /param/:param"
- spanType DDSpanTypes.HTTP_SERVER
- errored false
- parent()
- tags {
- "$Tags.COMPONENT" "jetty-server"
- "$Tags.SPAN_KIND" Tags.SPAN_KIND_SERVER
- "$Tags.PEER_HOST_IPV4" "127.0.0.1"
- "$Tags.PEER_PORT" Integer
- "$Tags.HTTP_URL" "http://localhost:$port/param/asdf1234"
- "$Tags.HTTP_HOSTNAME" "localhost"
- "$Tags.HTTP_METHOD" "GET"
- "$Tags.HTTP_STATUS" 200
- "$Tags.HTTP_ROUTE" String
- "$Tags.HTTP_USER_AGENT" String
- "$Tags.HTTP_CLIENT_IP" "127.0.0.1"
- "$Tags.NETWORK_CLIENT_IP" "127.0.0.1"
- defaultTags()
- }
- }
- }
- }
- }
-}
diff --git a/dd-java-agent/instrumentation/spark/sparkjava-2.3/src/test/java/TestSparkJavaApplication.java b/dd-java-agent/instrumentation/spark/sparkjava-2.3/src/test/java/TestSparkJavaApplication.java
deleted file mode 100644
index 93f904c7206..00000000000
--- a/dd-java-agent/instrumentation/spark/sparkjava-2.3/src/test/java/TestSparkJavaApplication.java
+++ /dev/null
@@ -1,19 +0,0 @@
-import spark.Spark;
-
-public class TestSparkJavaApplication {
-
- public static void initSpark(final int port) {
- Spark.port(port);
- Spark.get("/", (req, res) -> "Hello World");
-
- Spark.get("/param/:param", (req, res) -> "Hello " + req.params("param"));
-
- Spark.get(
- "/exception/:param",
- (req, res) -> {
- throw new RuntimeException(req.params("param"));
- });
-
- Spark.awaitInitialization();
- }
-}
diff --git a/dd-java-agent/instrumentation/spark/sparkjava-2.3/src/test/java/datadog/trace/instrumentation/sparkjava/SparkJavaForkedTest.java b/dd-java-agent/instrumentation/spark/sparkjava-2.3/src/test/java/datadog/trace/instrumentation/sparkjava/SparkJavaForkedTest.java
new file mode 100644
index 00000000000..9b5de61b72b
--- /dev/null
+++ b/dd-java-agent/instrumentation/spark/sparkjava-2.3/src/test/java/datadog/trace/instrumentation/sparkjava/SparkJavaForkedTest.java
@@ -0,0 +1,226 @@
+package datadog.trace.instrumentation.sparkjava;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import datadog.trace.agent.test.AbstractInstrumentationTest;
+import datadog.trace.agent.test.utils.PortUtils;
+import datadog.trace.core.DDSpan;
+import java.io.BufferedReader;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeoutException;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import spark.Request;
+import spark.Response;
+import spark.Route;
+import spark.Spark;
+
+/**
+ * Forked test for the SparkJava 2.x instrumentation, running in an isolated JVM. This validates
+ * that the {@link RoutesInstrumentation} loads and enriches Jetty server spans correctly when the
+ * agent starts from scratch — no leftover state from other test classes.
+ *
+ *
This test focuses on the core enrichment contract: when a request matches a SparkJava route,
+ * the server span gets operation name {@code spark.request}, component {@code spark-java}, and the
+ * resource name / http.route reflect the parameterized route pattern.
+ */
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+public class SparkJavaForkedTest extends AbstractInstrumentationTest {
+
+ private int actualPort;
+
+ @BeforeAll
+ void setupServer() {
+ actualPort = PortUtils.randomOpenPort();
+ Spark.port(actualPort);
+
+ Spark.get(
+ "/ping",
+ new Route() {
+ @Override
+ public Object handle(Request request, Response response) {
+ response.type("text/plain");
+ return "pong";
+ }
+ });
+
+ Spark.get(
+ "/items/:id",
+ new Route() {
+ @Override
+ public Object handle(Request request, Response response) {
+ response.type("application/json");
+ return "{\"id\": \"" + request.params(":id") + "\"}";
+ }
+ });
+
+ Spark.get(
+ "/fail",
+ new Route() {
+ @Override
+ public Object handle(Request request, Response response) {
+ throw new RuntimeException("Forked test error");
+ }
+ });
+
+ Spark.awaitInitialization();
+ }
+
+ @AfterAll
+ void tearDownServer() throws InterruptedException {
+ Spark.stop();
+ Thread.sleep(500);
+ }
+
+ @Test
+ void simpleRouteEnrichesServerSpan() throws InterruptedException, TimeoutException {
+ httpGet("/ping");
+
+ DDSpan serverSpan = waitForServerSpan();
+ assertServerSpan(serverSpan, "GET", "/ping", 200, false);
+ }
+
+ @Test
+ void parameterizedRoutePatternInResourceName() throws InterruptedException, TimeoutException {
+ httpGet("/items/42");
+
+ DDSpan serverSpan = waitForServerSpan();
+ assertServerSpan(serverSpan, "GET", "/items/:id", 200, false);
+ }
+
+ @Test
+ void errorRouteProducesErrorSpan() throws InterruptedException, TimeoutException {
+ httpGet("/fail");
+
+ DDSpan serverSpan = waitForServerSpan();
+ assertServerSpan(serverSpan, "GET", "/fail", 500, true);
+ }
+
+ // ---------------------------------------------------------------
+ // Helper methods
+ // ---------------------------------------------------------------
+
+ /**
+ * Validates the complete structure of a server span, covering both SparkJava enrichment and the
+ * underlying Jetty server span baseline. This single-point-of-assertion prevents regressions when
+ * new required tags are added.
+ *
+ *
SparkJava enrichment (set by {@link RoutesInstrumentation}):
+ *
+ *
+ * - operation name = {@code spark.request}
+ *
- component = {@code spark-java}
+ *
- resource name = {@code HTTP_METHOD route_pattern}
+ *
- http.route = parameterized route pattern
+ *
+ *
+ * Jetty baseline (set by the Jetty server instrumentation):
+ *
+ *
+ * - span type = {@code web}
+ *
- span.kind = {@code server}
+ *
- http.method, http.status_code, http.url
+ *
- error flag (from HTTP status code)
+ *
+ *
+ * @param span the server span to validate
+ * @param httpMethod the expected HTTP method (e.g., "GET", "POST")
+ * @param route the expected route pattern (e.g., "/items/:id")
+ * @param statusCode the expected HTTP status code
+ * @param isError whether the span should be marked as errored
+ */
+ private void assertServerSpan(
+ DDSpan span, String httpMethod, String route, int statusCode, boolean isError) {
+ assertNotNull(span, "Expected a server span for " + httpMethod + " " + route);
+
+ // SparkJava enrichment assertions
+ assertEquals(
+ "spark.request",
+ span.getOperationName().toString(),
+ "Operation name should be 'spark.request'");
+ assertEquals(
+ "spark-java",
+ String.valueOf(span.getTag("component")),
+ "component tag should be 'spark-java'");
+ assertEquals(
+ httpMethod + " " + route,
+ span.getResourceName().toString(),
+ "Resource name should be HTTP_METHOD + route_pattern");
+ assertEquals(
+ route,
+ String.valueOf(span.getTag("http.route")),
+ "http.route should contain the route pattern, not the actual path");
+
+ // Jetty baseline assertions
+ assertEquals("web", span.getSpanType(), "Span type should be 'web'");
+ assertEquals(
+ "server", String.valueOf(span.getTag("span.kind")), "span.kind should be 'server'");
+ assertEquals(httpMethod, String.valueOf(span.getTag("http.method")), "http.method tag");
+ assertEquals(statusCode, span.getTag("http.status_code"), "http.status_code tag");
+ assertNotNull(span.getTag("http.url"), "http.url tag should be set");
+ assertEquals(isError, span.isError(), "error flag");
+ }
+
+ /**
+ * Waits for at least one trace to be written and returns the server span.
+ *
+ * @return the server span (never null — fails assertion if not found)
+ * @throws InterruptedException if the thread is interrupted while waiting
+ * @throws TimeoutException if no trace is written within the timeout
+ */
+ private DDSpan waitForServerSpan() throws InterruptedException, TimeoutException {
+ writer.waitForTraces(1);
+ List spans = new ArrayList<>();
+ for (List trace : writer) {
+ spans.addAll(trace);
+ }
+ DDSpan serverSpan = null;
+ for (DDSpan span : spans) {
+ if ("server".equals(String.valueOf(span.getTag("span.kind")))
+ || "web".equals(span.getSpanType())) {
+ serverSpan = span;
+ break;
+ }
+ }
+ assertNotNull(serverSpan, "Expected to find a server span in the collected traces");
+ return serverSpan;
+ }
+
+ /**
+ * Makes an HTTP GET request to the SparkJava server.
+ *
+ * @param path the request path
+ * @return the HTTP status code
+ */
+ private int httpGet(String path) {
+ try {
+ URL url = new URL("http://localhost:" + actualPort + path);
+ HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+ conn.setRequestMethod("GET");
+ conn.setConnectTimeout(5000);
+ conn.setReadTimeout(5000);
+ int status = conn.getResponseCode();
+ InputStream is =
+ conn.getResponseCode() >= 400 ? conn.getErrorStream() : conn.getInputStream();
+ if (is != null) {
+ BufferedReader reader = new BufferedReader(new InputStreamReader(is));
+ while (reader.readLine() != null) {
+ // drain
+ }
+ reader.close();
+ }
+ conn.disconnect();
+ return status;
+ } catch (Exception e) {
+ throw new RuntimeException("HTTP GET failed for path " + path, e);
+ }
+ }
+}
diff --git a/dd-java-agent/instrumentation/spark/sparkjava-2.3/src/test/java/datadog/trace/instrumentation/sparkjava/SparkJavaTest.java b/dd-java-agent/instrumentation/spark/sparkjava-2.3/src/test/java/datadog/trace/instrumentation/sparkjava/SparkJavaTest.java
new file mode 100644
index 00000000000..6abafc9051b
--- /dev/null
+++ b/dd-java-agent/instrumentation/spark/sparkjava-2.3/src/test/java/datadog/trace/instrumentation/sparkjava/SparkJavaTest.java
@@ -0,0 +1,687 @@
+package datadog.trace.instrumentation.sparkjava;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import datadog.trace.agent.test.AbstractInstrumentationTest;
+import datadog.trace.agent.test.utils.PortUtils;
+import datadog.trace.api.DDTraceId;
+import datadog.trace.core.DDSpan;
+import java.io.BufferedReader;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeoutException;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import spark.Request;
+import spark.Response;
+import spark.Route;
+import spark.Spark;
+
+/**
+ * Tests for the SparkJava 2.x HTTP server instrumentation.
+ *
+ * SparkJava runs on an embedded Jetty server. The Jetty instrumentation creates the server span,
+ * and the SparkJava {@link RoutesInstrumentation} enriches it with route information from the
+ * {@code Routes.find()} method.
+ *
+ *
Acceptance criteria verified by these tests:
+ *
+ *
+ * - A server span is created for each HTTP request handled by a SparkJava route
+ *
- The operation name is set to {@code spark.request}
+ *
- The span type is {@code web} and span.kind is {@code server}
+ *
- The component tag is set to {@code spark-java}
+ *
- The resource name is enriched to {@code HTTP_METHOD route_pattern} (e.g., {@code GET
+ * /hello/:name})
+ *
- The http.route tag contains the parameterized route pattern, not the concrete path
+ *
- HTTP tags (method, URL, status code) are set correctly
+ *
- Error routes (500) set the error flag on the span
+ *
- Unmatched routes (404) retain Jetty defaults — no SparkJava enrichment fires
+ *
- Context propagation via Datadog headers links server spans to parent traces
+ *
+ */
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+public class SparkJavaTest extends AbstractInstrumentationTest {
+
+ private int actualPort;
+
+ @BeforeAll
+ void setupServer() {
+ actualPort = PortUtils.randomOpenPort();
+ Spark.port(actualPort);
+
+ Spark.get(
+ "/hello",
+ new Route() {
+ @Override
+ public Object handle(Request request, Response response) {
+ response.type("text/plain");
+ return "Hello, World!";
+ }
+ });
+
+ Spark.get(
+ "/hello/:name",
+ new Route() {
+ @Override
+ public Object handle(Request request, Response response) {
+ String name = request.params(":name");
+ response.type("text/plain");
+ return "Hello, " + name + "!";
+ }
+ });
+
+ Spark.post(
+ "/users",
+ new Route() {
+ @Override
+ public Object handle(Request request, Response response) {
+ response.type("application/json");
+ response.status(201);
+ return "{\"created\": true}";
+ }
+ });
+
+ Spark.put(
+ "/users/:id",
+ new Route() {
+ @Override
+ public Object handle(Request request, Response response) {
+ String id = request.params(":id");
+ response.type("application/json");
+ return "{\"updated\": true, \"id\": \"" + id + "\"}";
+ }
+ });
+
+ Spark.delete(
+ "/users/:id",
+ new Route() {
+ @Override
+ public Object handle(Request request, Response response) {
+ String id = request.params(":id");
+ response.type("application/json");
+ return "{\"deleted\": true, \"id\": \"" + id + "\"}";
+ }
+ });
+
+ Spark.get(
+ "/error",
+ new Route() {
+ @Override
+ public Object handle(Request request, Response response) {
+ throw new RuntimeException("Intentional error for testing");
+ }
+ });
+
+ Spark.get(
+ "/files/*",
+ new Route() {
+ @Override
+ public Object handle(Request request, Response response) {
+ response.type("text/plain");
+ return "file content for " + request.splat()[0];
+ }
+ });
+
+ Spark.before(
+ "/filtered/*",
+ new spark.Filter() {
+ @Override
+ public void handle(Request request, Response response) {
+ response.header("X-Filtered", "true");
+ }
+ });
+
+ Spark.get(
+ "/filtered/resource",
+ new Route() {
+ @Override
+ public Object handle(Request request, Response response) {
+ response.type("text/plain");
+ return "filtered response";
+ }
+ });
+
+ Spark.after(
+ "/after-filtered/*",
+ new spark.Filter() {
+ @Override
+ public void handle(Request request, Response response) {
+ response.header("X-After-Filtered", "true");
+ }
+ });
+
+ Spark.get(
+ "/after-filtered/resource",
+ new Route() {
+ @Override
+ public Object handle(Request request, Response response) {
+ response.type("text/plain");
+ return "after-filtered response";
+ }
+ });
+
+ Spark.awaitInitialization();
+ }
+
+ @AfterAll
+ void tearDownServer() throws InterruptedException {
+ Spark.stop();
+ Thread.sleep(500);
+ }
+
+ // ---------------------------------------------------------------
+ // Route enrichment tests — verify SparkJava sets operation name,
+ // component, resource name, and http.route on the Jetty server span
+ // ---------------------------------------------------------------
+
+ @Test
+ void getRouteCreatesServerSpanWithCorrectTags() throws InterruptedException, TimeoutException {
+ httpGet("/hello");
+
+ DDSpan serverSpan = waitForServerSpan();
+ assertServerSpan(serverSpan, "GET", "/hello", 200, false);
+ }
+
+ @Test
+ void getRouteWithPathParamUsesParameterizedRoutePattern()
+ throws InterruptedException, TimeoutException {
+ httpGet("/hello/spark-user");
+
+ DDSpan serverSpan = waitForServerSpan();
+ // The route pattern should be /hello/:name (parameterized), not /hello/spark-user (actual path)
+ assertServerSpan(serverSpan, "GET", "/hello/:name", 200, false);
+ }
+
+ @Test
+ void postRouteCreatesServerSpanWithCorrectStatusCode()
+ throws InterruptedException, TimeoutException {
+ httpRequest("/users", "POST", "test-body");
+
+ DDSpan serverSpan = waitForServerSpan();
+ assertServerSpan(serverSpan, "POST", "/users", 201, false);
+ }
+
+ @Test
+ void putRouteWithPathParamCreatesServerSpan() throws InterruptedException, TimeoutException {
+ httpRequest("/users/42", "PUT", "update-body");
+
+ DDSpan serverSpan = waitForServerSpan();
+ assertServerSpan(serverSpan, "PUT", "/users/:id", 200, false);
+ }
+
+ @Test
+ void deleteRouteWithPathParamCreatesServerSpan() throws InterruptedException, TimeoutException {
+ httpRequest("/users/99", "DELETE", null);
+
+ DDSpan serverSpan = waitForServerSpan();
+ assertServerSpan(serverSpan, "DELETE", "/users/:id", 200, false);
+ }
+
+ @Test
+ void wildcardRouteUsesWildcardPattern() throws InterruptedException, TimeoutException {
+ httpGet("/files/documents/report.pdf");
+
+ DDSpan serverSpan = waitForServerSpan();
+ assertServerSpan(serverSpan, "GET", "/files/*", 200, false);
+ }
+
+ @Test
+ void beforeFilterDoesNotInterfereWithRouteEnrichment()
+ throws InterruptedException, TimeoutException {
+ httpGet("/filtered/resource");
+
+ DDSpan serverSpan = waitForServerSpan();
+ assertServerSpan(serverSpan, "GET", "/filtered/resource", 200, false);
+ }
+
+ @Test
+ void afterFilterDoesNotInterfereWithSpanData() throws InterruptedException, TimeoutException {
+ httpGet("/after-filtered/resource");
+
+ DDSpan serverSpan = waitForServerSpan();
+ assertServerSpan(serverSpan, "GET", "/after-filtered/resource", 200, false);
+ }
+
+ // ---------------------------------------------------------------
+ // Span structure tests — verify individual span attributes
+ // ---------------------------------------------------------------
+
+ @Test
+ void serverSpanHasCorrectType() throws InterruptedException, TimeoutException {
+ httpGet("/hello");
+
+ DDSpan serverSpan = waitForServerSpan();
+ assertEquals("web", serverSpan.getSpanType(), "HTTP server spans should have type 'web'");
+ assertEquals(
+ "server",
+ String.valueOf(serverSpan.getTag("span.kind")),
+ "Span kind should be 'server' for HTTP server spans");
+ }
+
+ @Test
+ void serverSpanHasCorrectOperationName() throws InterruptedException, TimeoutException {
+ httpGet("/hello");
+
+ DDSpan serverSpan = waitForServerSpan();
+ assertEquals(
+ "spark.request",
+ serverSpan.getOperationName().toString(),
+ "Operation name should be 'spark.request' for SparkJava routes");
+ }
+
+ @Test
+ void serverSpanIncludesHttpUrlTag() throws InterruptedException, TimeoutException {
+ httpGet("/hello");
+
+ DDSpan serverSpan = waitForServerSpan();
+ String httpUrl = String.valueOf(serverSpan.getTag("http.url"));
+ assertNotNull(httpUrl, "Expected http.url tag to be set");
+ assertTrue(
+ httpUrl.contains("/hello"),
+ "http.url tag should contain the request path, got: " + httpUrl);
+ assertTrue(httpUrl.startsWith("http"), "http.url tag should be a full URL, got: " + httpUrl);
+ }
+
+ // ---------------------------------------------------------------
+ // Error handling tests
+ // ---------------------------------------------------------------
+
+ @Test
+ void errorRouteCreatesServerSpanWithErrorFlag() throws InterruptedException, TimeoutException {
+ httpGet("/error");
+
+ DDSpan serverSpan = waitForServerSpan();
+ assertServerSpan(serverSpan, "GET", "/error", 500, true);
+ // SparkJava catches exceptions internally via its ExceptionMapper before they propagate
+ // to Jetty. The error flag is set solely from the 500 status code by Jetty's
+ // HttpServerDecorator.onResponse(). Because the exception never reaches the Jetty handler,
+ // error.type/error.message/error.stack are not populated on the span.
+ assertNull(
+ serverSpan.getTag("error.type"),
+ "error.type should not be set — SparkJava catches exceptions before Jetty sees them");
+ assertNull(
+ serverSpan.getTag("error.message"),
+ "error.message should not be set — SparkJava catches exceptions before Jetty sees them");
+ assertNull(
+ serverSpan.getTag("error.stack"),
+ "error.stack should not be set — SparkJava catches exceptions before Jetty sees them");
+ }
+
+ @Test
+ void notFoundRouteCreates404Span() throws InterruptedException, TimeoutException {
+ httpGet("/nonexistent");
+
+ DDSpan serverSpan = waitForServerSpan();
+ // For 404, Routes.find() returns null so SparkJava enrichment does not fire.
+ // The span retains Jetty defaults — no http.route or spark-java component tag is expected.
+ // We can't use assertServerSpan() here because it asserts SparkJava-specific enrichment
+ // (operation name, component, http.route) that won't be present on an unmatched route.
+ assertEquals("web", serverSpan.getSpanType(), "Span type should be 'web' even for 404");
+ assertEquals(
+ "server", String.valueOf(serverSpan.getTag("span.kind")), "span.kind should be 'server'");
+ assertEquals(404, serverSpan.getTag("http.status_code"), "http.status_code should be 404");
+ assertEquals("GET", String.valueOf(serverSpan.getTag("http.method")), "http.method tag");
+ assertNotNull(serverSpan.getTag("http.url"), "http.url tag should be set even for 404");
+ assertEquals(false, serverSpan.isError(), "404 should not be marked as an error");
+ }
+
+ // ---------------------------------------------------------------
+ // Context propagation tests
+ // ---------------------------------------------------------------
+
+ @Test
+ void contextPropagationLinksServerSpanToParentTrace()
+ throws InterruptedException, TimeoutException {
+ Map headers = new HashMap<>();
+ headers.put("x-datadog-trace-id", "123456789");
+ headers.put("x-datadog-parent-id", "987654321");
+ httpGetWithHeaders("/hello", headers);
+
+ DDSpan serverSpan = waitForServerSpan();
+ assertServerSpan(serverSpan, "GET", "/hello", 200, false);
+ assertEquals(
+ DDTraceId.from("123456789"),
+ serverSpan.getTraceId(),
+ "Server span should inherit the trace ID from the propagated Datadog headers");
+ assertEquals(
+ 987654321L,
+ serverSpan.getParentId(),
+ "Server span's parent ID should match the x-datadog-parent-id header value");
+ }
+
+ @Test
+ void contextPropagationPreservesSparkJavaRouteEnrichment()
+ throws InterruptedException, TimeoutException {
+ Map headers = new HashMap<>();
+ headers.put("x-datadog-trace-id", "111111111");
+ headers.put("x-datadog-parent-id", "222222222");
+ httpGetWithHeaders("/hello", headers);
+
+ DDSpan serverSpan = waitForServerSpan();
+ // Verify SparkJava route enrichment still works with propagated context
+ assertServerSpan(serverSpan, "GET", "/hello", 200, false);
+ // Verify context propagation
+ assertEquals(
+ DDTraceId.from("111111111"),
+ serverSpan.getTraceId(),
+ "Trace ID should be inherited from propagated headers");
+ assertEquals(222222222L, serverSpan.getParentId());
+ }
+
+ @Test
+ void contextPropagationWorksWithParameterizedRoutes()
+ throws InterruptedException, TimeoutException {
+ Map headers = new HashMap<>();
+ headers.put("x-datadog-trace-id", "333333333");
+ headers.put("x-datadog-parent-id", "444444444");
+ httpGetWithHeaders("/hello/sparkuser", headers);
+
+ DDSpan serverSpan = waitForServerSpan();
+ assertServerSpan(serverSpan, "GET", "/hello/:name", 200, false);
+ assertEquals(
+ DDTraceId.from("333333333"),
+ serverSpan.getTraceId(),
+ "Trace ID should be inherited from propagated headers");
+ assertEquals(
+ 444444444L, serverSpan.getParentId(), "Parent ID should match propagated header value");
+ }
+
+ @Test
+ void contextPropagationPreservesErrorStatusOnErrorRoutes()
+ throws InterruptedException, TimeoutException {
+ Map headers = new HashMap<>();
+ headers.put("x-datadog-trace-id", "555555555");
+ headers.put("x-datadog-parent-id", "666666666");
+ httpGetWithHeaders("/error", headers);
+
+ DDSpan serverSpan = waitForServerSpan();
+ assertServerSpan(serverSpan, "GET", "/error", 500, true);
+ assertEquals(
+ DDTraceId.from("555555555"),
+ serverSpan.getTraceId(),
+ "Trace ID should be inherited even for error routes");
+ assertEquals(
+ 666666666L,
+ serverSpan.getParentId(),
+ "Parent ID should match propagated header even for error routes");
+ }
+
+ @Test
+ void differentPropagatedContextsProduceDistinctTraces()
+ throws InterruptedException, TimeoutException {
+ Map headers1 = new HashMap<>();
+ headers1.put("x-datadog-trace-id", "100000001");
+ headers1.put("x-datadog-parent-id", "200000001");
+ httpGetWithHeaders("/hello", headers1);
+
+ Map headers2 = new HashMap<>();
+ headers2.put("x-datadog-trace-id", "100000002");
+ headers2.put("x-datadog-parent-id", "200000002");
+ httpGetWithHeaders("/hello", headers2);
+
+ writer.waitForTraces(2);
+ List allSpans = flattenTraces();
+
+ // Find both server spans
+ DDSpan firstServerSpan = null;
+ DDSpan secondServerSpan = null;
+ for (DDSpan span : allSpans) {
+ if ("server".equals(String.valueOf(span.getTag("span.kind")))
+ || "web".equals(span.getSpanType())) {
+ if (DDTraceId.from("100000001").equals(span.getTraceId())) {
+ firstServerSpan = span;
+ } else if (DDTraceId.from("100000002").equals(span.getTraceId())) {
+ secondServerSpan = span;
+ }
+ }
+ }
+
+ assertNotNull(firstServerSpan, "Expected server span for first request (trace 100000001)");
+ assertNotNull(secondServerSpan, "Expected server span for second request (trace 100000002)");
+
+ // Verify each span links to its own propagated context
+ assertNotEquals(
+ firstServerSpan.getTraceId(),
+ secondServerSpan.getTraceId(),
+ "Each request should have its own distinct trace ID from propagated context");
+ assertEquals(200000001L, firstServerSpan.getParentId());
+ assertEquals(200000002L, secondServerSpan.getParentId());
+
+ // Both should still have correct route enrichment
+ assertEquals("GET /hello", firstServerSpan.getResourceName().toString());
+ assertEquals("GET /hello", secondServerSpan.getResourceName().toString());
+ }
+
+ // ---------------------------------------------------------------
+ // Helper methods
+ // ---------------------------------------------------------------
+
+ /**
+ * Waits for at least one trace to be written, then finds and returns the server span. This
+ * combines the common pattern of waiting + flattening + finding into a single call, reducing
+ * boilerplate in test methods.
+ *
+ * @return the server span (never null — fails assertion if not found)
+ * @throws InterruptedException if the thread is interrupted while waiting
+ * @throws TimeoutException if no trace is written within the timeout
+ */
+ private DDSpan waitForServerSpan() throws InterruptedException, TimeoutException {
+ writer.waitForTraces(1);
+ List spans = flattenTraces();
+ DDSpan serverSpan = findServerSpan(spans);
+ assertNotNull(serverSpan, "Expected to find a server span in the collected traces");
+ return serverSpan;
+ }
+
+ /**
+ * Flattens all collected traces into a single list of spans for easier assertion.
+ *
+ * @return all spans from all collected traces
+ */
+ private List flattenTraces() {
+ List result = new ArrayList<>();
+ for (List trace : writer) {
+ result.addAll(trace);
+ }
+ return result;
+ }
+
+ /**
+ * Finds the server span in the list of spans. The server span is identified by having {@code
+ * span.kind=server} or by having a {@code web} span type.
+ *
+ * @param spans the list of spans to search
+ * @return the server span, or {@code null} if not found
+ */
+ private DDSpan findServerSpan(List spans) {
+ for (DDSpan span : spans) {
+ if ("server".equals(String.valueOf(span.getTag("span.kind")))
+ || "web".equals(span.getSpanType())) {
+ return span;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Validates the complete structure of a server span, covering both SparkJava enrichment and the
+ * underlying Jetty server span baseline. This single-point-of-assertion prevents regressions when
+ * new required tags are added.
+ *
+ * SparkJava enrichment (set by {@link RoutesInstrumentation}):
+ *
+ *
+ * - operation name = {@code spark.request}
+ *
- component = {@code spark-java}
+ *
- resource name = {@code HTTP_METHOD route_pattern}
+ *
- http.route = parameterized route pattern
+ *
+ *
+ * Jetty baseline (set by the Jetty server instrumentation):
+ *
+ *
+ * - span type = {@code web}
+ *
- span.kind = {@code server}
+ *
- http.method, http.status_code, http.url
+ *
- error flag (from HTTP status code)
+ *
+ *
+ * @param span the server span to validate
+ * @param httpMethod the expected HTTP method (e.g., "GET", "POST")
+ * @param route the expected route pattern (e.g., "/hello/:name")
+ * @param statusCode the expected HTTP status code
+ * @param isError whether the span should be marked as errored
+ */
+ private void assertServerSpan(
+ DDSpan span, String httpMethod, String route, int statusCode, boolean isError) {
+ assertNotNull(span, "Expected a server span for " + httpMethod + " " + route);
+
+ // SparkJava enrichment assertions
+ assertEquals(
+ "spark.request",
+ span.getOperationName().toString(),
+ "Operation name should be 'spark.request'");
+ assertEquals(
+ "spark-java",
+ String.valueOf(span.getTag("component")),
+ "component tag should be 'spark-java'");
+ assertEquals(
+ httpMethod + " " + route,
+ span.getResourceName().toString(),
+ "Resource name should be HTTP_METHOD + route_pattern");
+ assertEquals(
+ route,
+ String.valueOf(span.getTag("http.route")),
+ "http.route should contain the route pattern, not the actual path");
+
+ // Jetty baseline assertions
+ assertEquals("web", span.getSpanType(), "Span type should be 'web'");
+ assertEquals(
+ "server", String.valueOf(span.getTag("span.kind")), "span.kind should be 'server'");
+ assertEquals(httpMethod, String.valueOf(span.getTag("http.method")), "http.method tag");
+ assertEquals(statusCode, span.getTag("http.status_code"), "http.status_code tag");
+ assertNotNull(span.getTag("http.url"), "http.url tag should be set");
+ assertEquals(isError, span.isError(), "error flag");
+ }
+
+ /**
+ * Makes an HTTP GET request to the SparkJava server with custom headers. Used for context
+ * propagation tests to inject Datadog trace headers (e.g., {@code x-datadog-trace-id}, {@code
+ * x-datadog-parent-id}) that simulate an upstream service propagating its trace context.
+ *
+ * @param path the request path (e.g., {@code /hello})
+ * @param headers map of header name to value to set on the request
+ * @return the HTTP status code
+ */
+ private int httpGetWithHeaders(String path, Map headers) {
+ try {
+ URL url = new URL("http://localhost:" + actualPort + path);
+ HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+ conn.setRequestMethod("GET");
+ conn.setConnectTimeout(5000);
+ conn.setReadTimeout(5000);
+ if (headers != null) {
+ for (Map.Entry entry : headers.entrySet()) {
+ conn.setRequestProperty(entry.getKey(), entry.getValue());
+ }
+ }
+ int status = conn.getResponseCode();
+ drainResponse(conn);
+ conn.disconnect();
+ return status;
+ } catch (Exception e) {
+ throw new RuntimeException("HTTP GET failed for path " + path, e);
+ }
+ }
+
+ /**
+ * Makes an HTTP GET request to the SparkJava server.
+ *
+ * @param path the request path (e.g., {@code /hello})
+ * @return the HTTP status code
+ */
+ private int httpGet(String path) {
+ try {
+ URL url = new URL("http://localhost:" + actualPort + path);
+ HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+ conn.setRequestMethod("GET");
+ conn.setConnectTimeout(5000);
+ conn.setReadTimeout(5000);
+ int status = conn.getResponseCode();
+ drainResponse(conn);
+ conn.disconnect();
+ return status;
+ } catch (Exception e) {
+ throw new RuntimeException("HTTP GET failed for path " + path, e);
+ }
+ }
+
+ /**
+ * Makes an HTTP request with the specified method and optional body.
+ *
+ * @param path the request path
+ * @param method the HTTP method (e.g., POST, PUT, DELETE)
+ * @param body the request body, or {@code null} for no body
+ * @return the HTTP status code
+ */
+ private int httpRequest(String path, String method, String body) {
+ try {
+ URL url = new URL("http://localhost:" + actualPort + path);
+ HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+ conn.setRequestMethod(method);
+ conn.setConnectTimeout(5000);
+ conn.setReadTimeout(5000);
+
+ if (body != null) {
+ conn.setDoOutput(true);
+ conn.setRequestProperty("Content-Type", "text/plain");
+ try (OutputStream os = conn.getOutputStream()) {
+ os.write(body.getBytes("UTF-8"));
+ }
+ }
+
+ int status = conn.getResponseCode();
+ drainResponse(conn);
+ conn.disconnect();
+ return status;
+ } catch (Exception e) {
+ throw new RuntimeException("HTTP " + method + " failed for path " + path, e);
+ }
+ }
+
+ /**
+ * Drains the response body to ensure the server-side processing completes fully before the
+ * connection is closed.
+ *
+ * @param conn the HTTP connection to drain
+ */
+ private void drainResponse(HttpURLConnection conn) {
+ try {
+ InputStream is =
+ conn.getResponseCode() >= 400 ? conn.getErrorStream() : conn.getInputStream();
+ if (is != null) {
+ BufferedReader reader = new BufferedReader(new InputStreamReader(is));
+ while (reader.readLine() != null) {
+ // drain
+ }
+ reader.close();
+ }
+ } catch (Exception ignored) {
+ // ignore drain errors
+ }
+ }
+}