diff --git a/microprofile/telemetry/pom.xml b/microprofile/telemetry/pom.xml
index cf39c879b34..03a93c3af11 100644
--- a/microprofile/telemetry/pom.xml
+++ b/microprofile/telemetry/pom.xml
@@ -157,7 +157,7 @@
**/AgentDetectorTest.java
**/TestSpanListenersWithInjection.java
- **/TestGlobalTracerAssignment.java
+ **/TestFilterSpanNesting.java
@@ -188,16 +188,13 @@
-
- test-global-tracer-assignment
+ filter-span-nesting-test
test
- **/TestGlobalTracerAssignment.java
+ **/TestFilterSpanNesting.java
diff --git a/microprofile/telemetry/src/main/java/io/helidon/microprofile/telemetry/HelidonTelemetryContainerFilter.java b/microprofile/telemetry/src/main/java/io/helidon/microprofile/telemetry/HelidonTelemetryContainerFilter.java
index fd69bddf46c..d5b0adc8e6f 100644
--- a/microprofile/telemetry/src/main/java/io/helidon/microprofile/telemetry/HelidonTelemetryContainerFilter.java
+++ b/microprofile/telemetry/src/main/java/io/helidon/microprofile/telemetry/HelidonTelemetryContainerFilter.java
@@ -140,8 +140,7 @@ public void filter(ContainerRequestContext requestContext) {
//Start new span for container request.
String route = route(requestContext);
- Optional extractedSpanContext =
- helidonTracer.extract(new RequestContextHeaderProvider(requestContext.getHeaders()));
+ Optional parentSpanContext = parentSpanContext(requestContext);
Span helidonSpan = helidonTracer.spanBuilder(spanName(requestContext, route))
.kind(Span.Kind.SERVER)
.tag(HTTP_METHOD, requestContext.getMethod())
@@ -150,7 +149,7 @@ public void filter(ContainerRequestContext requestContext) {
.tag(HTTP_ROUTE, route)
.tag(SemanticAttributes.NET_HOST_NAME.getKey(), requestContext.getUriInfo().getBaseUri().getHost())
.tag(SemanticAttributes.NET_HOST_PORT.getKey(), requestContext.getUriInfo().getBaseUri().getPort())
- .update(builder -> extractedSpanContext.ifPresent(builder::parent))
+ .update(builder -> parentSpanContext.ifPresent(builder::parent))
.start();
Scope helidonScope = helidonSpan.activate();
@@ -203,9 +202,11 @@ public void filter(final ContainerRequestContext request, final ContainerRespons
}
}
-// private static List helpers() {
-// return HelidonServiceLoader.create(ServiceLoader.load(HelidonTelemetryContainerFilterHelper.class)).asList();
-// }
+ private Optional parentSpanContext(ContainerRequestContext requestContext) {
+ // Prefer the current span if there is one.
+ return Span.current().map(Span::context)
+ .or(() -> helidonTracer.extract(new RequestContextHeaderProvider(requestContext.getHeaders())));
+ }
private String spanName(ContainerRequestContext requestContext, String route) {
// @Deprecated(forRemoval = true) In 5.x remove the option of excluding the HTTP method from the REST span name.
diff --git a/microprofile/telemetry/src/test/java/io/helidon/microprofile/telemetry/TestFilterSpanNesting.java b/microprofile/telemetry/src/test/java/io/helidon/microprofile/telemetry/TestFilterSpanNesting.java
new file mode 100644
index 00000000000..e022f70175e
--- /dev/null
+++ b/microprofile/telemetry/src/test/java/io/helidon/microprofile/telemetry/TestFilterSpanNesting.java
@@ -0,0 +1,156 @@
+/*
+ * Copyright (c) 2025 Oracle and/or its affiliates.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.helidon.microprofile.telemetry;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+
+import io.helidon.common.testing.junit5.OptionalMatcher;
+import io.helidon.microprofile.testing.junit5.AddBean;
+import io.helidon.microprofile.testing.junit5.AddConfig;
+import io.helidon.microprofile.testing.junit5.HelidonTest;
+import io.helidon.tracing.Scope;
+import io.helidon.tracing.Span;
+import io.helidon.tracing.SpanContext;
+import io.helidon.tracing.Tracer;
+
+import io.opentelemetry.sdk.trace.data.SpanData;
+import jakarta.annotation.Priority;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Priorities;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.client.WebTarget;
+import jakarta.ws.rs.container.ContainerRequestContext;
+import jakarta.ws.rs.container.ContainerRequestFilter;
+import jakarta.ws.rs.container.ContainerResponseContext;
+import jakarta.ws.rs.container.ContainerResponseFilter;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Request;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.ext.Provider;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.is;
+
+@HelidonTest
+@AddBean(TestSpanExporter.class)
+@AddBean(TestFilterSpanNesting.TestBean.class)
+@AddBean(TestFilterSpanNesting.IngressSpanSetter.class)
+@AddConfig(key = "otel.sdk.disabled", value = "false")
+@AddConfig(key = "otel.traces.exporter", value = "in-memory")
+class TestFilterSpanNesting {
+
+ private static Tracer staticTracer;
+
+ @Inject
+ private WebTarget webTarget;
+
+ @Inject
+ private TestSpanExporter testSpanExporter;
+
+ @Inject
+ public TestFilterSpanNesting(Tracer tracer) {
+ staticTracer = tracer;
+ }
+
+ @BeforeEach
+ void setUp() {
+ testSpanExporter.clear();
+ }
+
+ @Test
+ void testExternalParentSpan() {
+
+ var requestBuilder = webTarget.path("/parentSpanCheck")
+ .request(MediaType.TEXT_PLAIN);
+
+ // Our client filter will automatically establish a span for the outgoing Jakarta REST client request.
+ Response response = requestBuilder.get();
+
+ assertThat("Response status", response.getStatus(), is(200));
+
+ // Check structure of nested spans.
+ List spanData = testSpanExporter.spanData(3);
+ Optional ingressSpanData = spanData.stream()
+ .filter(sd -> sd.getName().equals("ingressSpan"))
+ .findFirst();
+ assertThat("ingress span data", ingressSpanData, OptionalMatcher.optionalPresent());
+
+ Optional spanFromJakartaFilter = spanData.stream()
+ .filter(sd -> sd.getName().equals("/parentSpanCheck"))
+ .findFirst();
+ assertThat("/parentSpanCheck span data", spanFromJakartaFilter, OptionalMatcher.optionalPresent());
+
+ // Make sure the parent for the span created by the container filter is the current span we set in our test filter,
+ // not the span inspired by the incoming headers.
+ assertThat("/parentSpanCheck parent span ID",
+ spanFromJakartaFilter.get().getParentSpanContext().getSpanId(),
+ equalTo(ingressSpanData.get().getSpanContext().getSpanId()));
+
+ }
+
+ @ApplicationScoped
+ @Path("/parentSpanCheck")
+ public static class TestBean {
+
+ @GET
+ @Produces(MediaType.TEXT_PLAIN)
+ public String parentSpanCheck(Request request) {
+ // The HelidonTelemetryContainerFilter should have been run to establish a new current span. Create a new child.
+ return "Hello World!";
+ }
+ }
+
+ /**
+ * Filter to kind-of play the role of upstream ingress code which sets a current span before our normal filter
+ * HelidonTelemetryContainerFilter runs.
+ */
+ @Provider
+ @Priority(Priorities.HEADER_DECORATOR)
+ static class IngressSpanSetter implements ContainerRequestFilter, ContainerResponseFilter {
+
+ private Span pseudoIngressSpan;
+ private Scope pseudoIngressScope;
+
+ @Override
+ public void filter(ContainerRequestContext requestContext) throws IOException {
+ // Create a span that's a child of the span represented in the headers and make it current.
+ // Then the HelidonTelemetryContainerFilter will find this one as current and the span *it* adds should be a child
+ // of this new pseudo-ingress span which we'll check in the test code.
+
+ Optional helidonSpanContext =
+ staticTracer.extract(new RequestContextHeaderProvider(requestContext.getHeaders()));
+
+ pseudoIngressSpan = staticTracer.spanBuilder("ingressSpan")
+ .update(spanBuilder -> helidonSpanContext.ifPresent(spanBuilder::parent))
+ .build();
+ pseudoIngressScope = pseudoIngressSpan.activate();
+ }
+
+ @Override
+ public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext) throws IOException {
+ pseudoIngressScope.close();
+ pseudoIngressSpan.end();
+ }
+ }
+}