This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new b2eb665ea77f CAMEL-26450: camel-telemetry - end the span of an
asynchronous step before the next step starts (#28806)
b2eb665ea77f is described below
commit b2eb665ea77f8fdc2aff7d4a202cb2f5894d48c3
Author: allthingssecurity <[email protected]>
AuthorDate: Mon Nov 2 16:22:40 2026 +0530
CAMEL-26450: camel-telemetry - end the span of an asynchronous step before
the next step starts (#28806)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../observability/AsyncProcessorSpanTest.java | 89 +++++++
.../opentelemetry2/AsyncProcessorSpanTest.java | 119 +++++++++
.../camel/telemetrydev/AsyncProcessorSpanTest.java | 92 +++++++
.../TraceProcessorsInterceptStrategy.java | 55 +++-
.../camel/telemetry/AsyncProcessorSpanTest.java | 284 +++++++++++++++++++++
.../telemetry/ProcessorSpanBeginFailureTest.java | 123 +++++++++
.../telemetry/ProcessorSpanEndFailureTest.java | 106 ++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 21 ++
8 files changed, 879 insertions(+), 10 deletions(-)
diff --git
a/components/camel-micrometer-observability/src/test/java/org/apache/camel/micrometer/observability/AsyncProcessorSpanTest.java
b/components/camel-micrometer-observability/src/test/java/org/apache/camel/micrometer/observability/AsyncProcessorSpanTest.java
new file mode 100644
index 000000000000..3c9b2f296f42
--- /dev/null
+++
b/components/camel-micrometer-observability/src/test/java/org/apache/camel/micrometer/observability/AsyncProcessorSpanTest.java
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.camel.micrometer.observability;
+
+import java.util.List;
+import java.util.Map;
+
+import io.opentelemetry.sdk.trace.data.SpanData;
+import org.apache.camel.CamelContext;
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.builder.RouteBuilder;
+import
org.apache.camel.micrometer.observability.CamelOpenTelemetryExtension.OtelTrace;
+import org.apache.camel.telemetry.Op;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * With Micrometer Observation on OpenTelemetry: the steps after an
asynchronous delay are children of the route span,
+ * and the delay span is ended before them.
+ */
+class AsyncProcessorSpanTest extends
MicrometerObservabilityTracerPropagationTestSupport {
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ CamelContext context = super.createCamelContext();
+ // the spans of the core processors (the support class disables them)
+ tst.setDisableCoreProcessors(false);
+ return context;
+ }
+
+ @Test
+ void stepsAfterAsyncDelayAreChildrenOfTheRoute() {
+ template.sendBody("direct:start", "my-body");
+ Map<String, OtelTrace> traces = otelExtension.getTraces();
+ assertEquals(1, traces.size());
+ List<SpanData> spans = traces.values().iterator().next().getSpans();
+ SpanData route = getSpan(spans, "direct://start", Op.EVENT_RECEIVED);
+ SpanData delay = byName(spans, "delay-delay");
+ SpanData step2 = byName(spans, "step2-setHeader");
+ SpanData step3 = byName(spans, "step3-setHeader");
+ for (SpanData span : spans) {
+ assertTrue(span.hasEnded(), "Span not ended: " + span.getName());
+ }
+ assertEquals(route.getSpanId(), delay.getParentSpanId());
+ assertEquals(route.getSpanId(), step2.getParentSpanId());
+ assertEquals(route.getSpanId(), step3.getParentSpanId());
+ assertTrue(delay.getEndEpochNanos() <= step2.getStartEpochNanos(),
"The delay span ends after the next step starts");
+ }
+
+ private static SpanData byName(List<SpanData> spans, String name) {
+ for (SpanData span : spans) {
+ if (name.equals(span.getName())) {
+ return span;
+ }
+ }
+ throw new IllegalArgumentException("No span " + name + " in " + spans);
+ }
+
+ @Override
+ protected RoutesBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ // a delay is asynchronous by default
+ from("direct:start").routeId("start")
+ .delay(10).id("delay")
+ .setHeader("step2", constant("2")).id("step2")
+ .setHeader("step3", constant("3")).id("step3");
+ }
+ };
+ }
+
+}
diff --git
a/components/camel-opentelemetry2/src/test/java/org/apache/camel/opentelemetry2/AsyncProcessorSpanTest.java
b/components/camel-opentelemetry2/src/test/java/org/apache/camel/opentelemetry2/AsyncProcessorSpanTest.java
new file mode 100644
index 000000000000..48a8754b4731
--- /dev/null
+++
b/components/camel-opentelemetry2/src/test/java/org/apache/camel/opentelemetry2/AsyncProcessorSpanTest.java
@@ -0,0 +1,119 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.camel.opentelemetry2;
+
+import java.util.List;
+import java.util.Map;
+
+import io.opentelemetry.sdk.trace.data.SpanData;
+import org.apache.camel.CamelContext;
+import org.apache.camel.CamelContextAware;
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.opentelemetry2.CamelOpenTelemetryExtension.OtelTrace;
+import org.apache.camel.telemetry.Op;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * With the OpenTelemetry SDK: the span of a step that completes on another
thread (an asynchronous delay, the threads
+ * EIP) ends before the next step of the route starts, and the next steps are
children of the route span.
+ */
+class AsyncProcessorSpanTest extends OpenTelemetryTracerTestSupport {
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ OpenTelemetryTracer tst = new OpenTelemetryTracer();
+ tst.setTracer(otelExtension.getOpenTelemetry().getTracer("traceTest"));
+
tst.setContextPropagators(otelExtension.getOpenTelemetry().getPropagators());
+ CamelContext context = super.createCamelContext();
+ CamelContextAware.trySetCamelContext(tst, context);
+ tst.init(context);
+ return context;
+ }
+
+ @Test
+ void stepsAfterAsyncDelayAreChildrenOfTheRoute() {
+ check("direct:delay", "async-delay", "delay");
+ }
+
+ @Test
+ void stepsAfterThreadsAreChildrenOfTheRoute() {
+ check("direct:threads", "threads-threads", "threads");
+ }
+
+ @Test
+ void stepsAfterSyncDelayAreChildrenOfTheRoute() {
+ // control: a delay that completes on the same thread
+ check("direct:syncdelay", "sync-delay", "sync");
+ }
+
+ private void check(String uri, String asyncStep, String prefix) {
+ template.sendBody(uri, "my-body");
+ Map<String, OtelTrace> traces = otelExtension.getTraces();
+ assertEquals(1, traces.size());
+ List<SpanData> spans = traces.values().iterator().next().getSpans();
+ SpanData route = getSpan(spans, uri.replace(":", "://"),
Op.EVENT_RECEIVED);
+ SpanData async = byName(spans, asyncStep);
+ SpanData step2 = byName(spans, prefix + "Step2-setHeader");
+ SpanData step3 = byName(spans, prefix + "Step3-setHeader");
+ for (SpanData span : spans) {
+ assertTrue(span.hasEnded(), "Span not ended: " + span.getName());
+ }
+ assertEquals(route.getSpanId(), async.getParentSpanId());
+ assertEquals(route.getSpanId(), step2.getParentSpanId());
+ assertEquals(route.getSpanId(), step3.getParentSpanId());
+ assertTrue(async.getEndEpochNanos() <= step2.getStartEpochNanos(),
+ "The span " + asyncStep + " ends after the next step starts");
+ }
+
+ private static SpanData byName(List<SpanData> spans, String name) {
+ for (SpanData span : spans) {
+ if (name.equals(span.getName())) {
+ return span;
+ }
+ }
+ throw new IllegalArgumentException("No span " + name + " in " + spans);
+ }
+
+ @Override
+ protected RoutesBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ // a delay is asynchronous by default
+ from("direct:delay").routeId("delayRoute")
+ .delay(10).id("async")
+ .setHeader("step2", constant("2")).id("delayStep2")
+ .setHeader("step3", constant("3")).id("delayStep3");
+
+ from("direct:threads").routeId("threadsRoute")
+ .threads(1).id("threads")
+ .setHeader("step2", constant("2")).id("threadsStep2")
+ .setHeader("step3", constant("3")).id("threadsStep3");
+
+ from("direct:syncdelay").routeId("syncDelayRoute")
+ .delay(10).syncDelayed().id("sync")
+ .setHeader("step2", constant("2")).id("syncStep2")
+ .setHeader("step3", constant("3")).id("syncStep3");
+ }
+ };
+ }
+
+}
diff --git
a/components/camel-telemetry-dev/src/test/java/org/apache/camel/telemetrydev/AsyncProcessorSpanTest.java
b/components/camel-telemetry-dev/src/test/java/org/apache/camel/telemetrydev/AsyncProcessorSpanTest.java
new file mode 100644
index 000000000000..7b6a7effc704
--- /dev/null
+++
b/components/camel-telemetry-dev/src/test/java/org/apache/camel/telemetrydev/AsyncProcessorSpanTest.java
@@ -0,0 +1,92 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.camel.telemetrydev;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.CamelContextAware;
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.telemetry.Op;
+import org.apache.camel.telemetry.TagConstants;
+import org.junit.jupiter.api.Test;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * The steps after an asynchronous delay are children of the route span (and
not of the delay span).
+ */
+class AsyncProcessorSpanTest extends TelemetryDevTracerTestSupport {
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ TelemetryDevTracer tst = new TelemetryDevTracer();
+ tst.setTraceFormat("json");
+ CamelContext context = super.createCamelContext();
+ CamelContextAware.trySetCamelContext(tst, context);
+ tst.init(context);
+ return context;
+ }
+
+ @Test
+ void stepsAfterAsyncDelayAreChildrenOfTheRoute() throws IOException {
+ template.sendBody("direct:start", "my-body");
+ // the trace is written to the log when all its spans are ended
+ await().atMost(10, TimeUnit.SECONDS).until(() ->
tracesFromLog().size() == 1);
+ Map<String, DevTrace> traces = tracesFromLog();
+ List<DevSpanAdapter> spans =
traces.values().iterator().next().getSpans();
+ // the template's send, the route, the delay and two steps
+ assertEquals(5, spans.size());
+ DevSpanAdapter route = getSpan(spans, "direct://start",
Op.EVENT_RECEIVED);
+ DevSpanAdapter delay = processorSpan(spans, "delay-delay");
+ DevSpanAdapter step2 = processorSpan(spans, "step2-setHeader");
+ DevSpanAdapter step3 = processorSpan(spans, "step3-setHeader");
+ assertEquals(route.getTag("spanid"), delay.getTag("parentSpan"));
+ assertEquals(route.getTag("spanid"), step2.getTag("parentSpan"));
+ assertEquals(route.getTag("spanid"), step3.getTag("parentSpan"));
+ }
+
+ private static DevSpanAdapter processorSpan(List<DevSpanAdapter> spans,
String component) {
+ for (DevSpanAdapter span : spans) {
+ if (component.equals(span.getTag(TagConstants.COMPONENT))
+ &&
Op.EVENT_PROCESS.toString().equals(span.getTag(TagConstants.OP))) {
+ return span;
+ }
+ }
+ throw new IllegalArgumentException("No processor span " + component);
+ }
+
+ @Override
+ protected RoutesBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ // a delay is asynchronous by default
+ from("direct:start").routeId("start")
+ .delay(10).id("delay")
+ .setHeader("step2", constant("2")).id("step2")
+ .setHeader("step3", constant("3")).id("step3");
+ }
+ };
+ }
+
+}
diff --git
a/components/camel-telemetry/src/main/java/org/apache/camel/telemetry/TraceProcessorsInterceptStrategy.java
b/components/camel-telemetry/src/main/java/org/apache/camel/telemetry/TraceProcessorsInterceptStrategy.java
index dc75cc4bd13c..420efa15a23f 100644
---
a/components/camel-telemetry/src/main/java/org/apache/camel/telemetry/TraceProcessorsInterceptStrategy.java
+++
b/components/camel-telemetry/src/main/java/org/apache/camel/telemetry/TraceProcessorsInterceptStrategy.java
@@ -28,12 +28,16 @@ import org.apache.camel.Processor;
import org.apache.camel.spi.InterceptStrategy;
import org.apache.camel.support.AsyncCallbackToCompletableFutureAdapter;
import org.apache.camel.support.processor.DelegateAsyncProcessor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
/**
* TraceProcessorsInterceptStrategy is used to wrap each processor calls and
generate a Span for each process execution.
*/
public class TraceProcessorsInterceptStrategy implements InterceptStrategy {
+ private static final Logger LOG =
LoggerFactory.getLogger(TraceProcessorsInterceptStrategy.class);
+
private Tracer tracer;
public TraceProcessorsInterceptStrategy(Tracer tracer) {
@@ -63,7 +67,14 @@ public class TraceProcessorsInterceptStrategy implements
InterceptStrategy {
tracer.beginProcessorSpan(exchange, processorName);
try {
processor.process(exchange);
- } finally {
+ } catch (Throwable e) {
+ // do not replace the exception of the processor with a
failure to end the span
+ endProcessorSpanKeepingFailure(exchange, processorName);
+ throw e;
+ }
+ if (exchange.getException() != null) {
+ endProcessorSpanKeepingFailure(exchange, processorName);
+ } else {
tracer.endProcessorSpan(exchange, processorName);
}
} else {
@@ -74,25 +85,36 @@ public class TraceProcessorsInterceptStrategy implements
InterceptStrategy {
@Override
public boolean process(Exchange exchange, AsyncCallback callback) {
String processorName = processorDefinition.getId() + "-" +
processorDefinition.getShortName();
- boolean trace = shouldTrace(processorName, exchange);
- if (trace) {
+ boolean started = false;
+ if (shouldTrace(processorName, exchange)) {
try {
tracer.beginProcessorSpan(exchange, processorName);
+ started = true;
} catch (Exception e) {
exchange.setException(e);
}
}
+ // only end the span when it was begun, as ending pulls the span
on top of the exchange's span stack
+ final boolean end = started;
return processor.process(exchange, doneSync -> {
try {
- callback.done(doneSync);
- } finally {
- if (trace) {
- try {
- tracer.endProcessorSpan(exchange, processorName);
- } catch (Exception e) {
- exchange.setException(e);
+ if (end) {
+ // end the span before the route continues: when the
processor completed on another thread (such
+ // as an asynchronous delay), callback.done runs the
next steps of the route inside this call,
+ // and they must not get this span as their parent
+ if (exchange.getException() != null) {
+ // do not replace an exception the processor has
already set on the exchange
+ endProcessorSpanKeepingFailure(exchange,
processorName);
+ } else {
+ try {
+ tracer.endProcessorSpan(exchange,
processorName);
+ } catch (Exception e) {
+ exchange.setException(e);
+ }
}
}
+ } finally {
+ callback.done(doneSync);
}
});
}
@@ -105,6 +127,19 @@ public class TraceProcessorsInterceptStrategy implements
InterceptStrategy {
return callback.getFuture();
}
+ /**
+ * Ends the span of a processor that failed: a failure to end the span
is logged, so that the exception of the
+ * processor is kept.
+ */
+ private void endProcessorSpanKeepingFailure(Exchange exchange, String
processorName) {
+ try {
+ tracer.endProcessorSpan(exchange, processorName);
+ } catch (Exception e) {
+ LOG.warn("Failed to end the processor span for {}. The
exception of the processor is kept.",
+ processorName, e);
+ }
+ }
+
private boolean shouldTrace(String processorName, Exchange exchange) {
// skip processors that send to an endpoint (to, toD, wireTap,
enrich)
// unwrap through any delegate chain (e.g. otel scope wrapper) to
find the real processor
diff --git
a/components/camel-telemetry/src/test/java/org/apache/camel/telemetry/AsyncProcessorSpanTest.java
b/components/camel-telemetry/src/test/java/org/apache/camel/telemetry/AsyncProcessorSpanTest.java
new file mode 100644
index 000000000000..e8b5ed6cd0b6
--- /dev/null
+++
b/components/camel-telemetry/src/test/java/org/apache/camel/telemetry/AsyncProcessorSpanTest.java
@@ -0,0 +1,284 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.camel.telemetry;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.CamelContextAware;
+import org.apache.camel.CamelExecutionException;
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.model.RouteDefinition;
+import org.apache.camel.telemetry.mock.MockSpanAdapter;
+import org.apache.camel.telemetry.mock.MockTrace;
+import org.apache.camel.telemetry.mock.MockTracer;
+import org.apache.camel.test.junit6.ExchangeTestSupport;
+import org.junit.jupiter.api.Test;
+
+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.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The span of a step that calls back from another thread (an asynchronous
delay, the threads EIP, a bean that returns a
+ * CompletableFuture) ends before the next step of the route starts, so the
next steps are children of the span that
+ * encloses the step (the route span, or the span of an EIP such as a choice)
and not of the asynchronous step.
+ */
+class AsyncProcessorSpanTest extends ExchangeTestSupport {
+
+ MockTracer mockTracer;
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ CamelContext context = super.createCamelContext();
+ this.mockTracer = new MockTracer();
+ CamelContextAware.trySetCamelContext(mockTracer, context);
+ mockTracer.init(context);
+ context.getRegistry().bind("futureBean", new FutureBean());
+ return context;
+ }
+
+ @Test
+ void stepsAfterAsyncDelayAreChildrenOfTheRoute() {
+ checkStepsAfter("direct:delay", "async-delay", "delay");
+ }
+
+ @Test
+ void stepsAfterThreadsAreChildrenOfTheRoute() {
+ checkStepsAfter("direct:threads", "threads-threads", "threads");
+ }
+
+ @Test
+ void stepsAfterBeanReturningFutureAreChildrenOfTheRoute() {
+ checkStepsAfter("direct:future", "future-bean", "future");
+ }
+
+ @Test
+ void stepsAfterSyncDelayAreChildrenOfTheRoute() {
+ // control: a delay that completes on the same thread (passes without
the fix as well)
+ checkStepsAfter("direct:syncdelay", "sync-delay", "sync");
+ }
+
+ @Test
+ void stepsAfterAsyncDelayInsideChoiceAreChildrenOfTheChoice() {
+ List<Span> spans = send("direct:choice");
+ MockSpanAdapter route = SpanTestSupport.getSpan(spans,
"direct://choice", Op.EVENT_RECEIVED);
+ MockSpanAdapter choice = span(spans, "choice-choice");
+ MockSpanAdapter delay = span(spans, "innerDelay-delay");
+ MockSpanAdapter inner = span(spans, "inner-setHeader");
+ MockSpanAdapter after = span(spans, "after-setHeader");
+ allDone(spans);
+ assertEquals(route.getTag("spanid"), choice.getTag("parentSpan"));
+ assertEquals(choice.getTag("spanid"), delay.getTag("parentSpan"));
+ assertEquals(choice.getTag("spanid"), inner.getTag("parentSpan"));
+ assertEquals(route.getTag("spanid"), after.getTag("parentSpan"));
+ endsBeforeStart(delay, inner);
+ endsBeforeStart(choice, after);
+ }
+
+ @Test
+ void calledRouteWithAsyncDelayEndsItsOwnSpans() {
+ List<Span> spans = send("direct:caller");
+ MockSpanAdapter caller = SpanTestSupport.getSpan(spans,
"direct://caller", Op.EVENT_RECEIVED);
+ MockSpanAdapter send = SpanTestSupport.getSpan(spans,
"direct://called", Op.EVENT_SENT);
+ MockSpanAdapter called = SpanTestSupport.getSpan(spans,
"direct://called", Op.EVENT_RECEIVED);
+ MockSpanAdapter calledDelay = span(spans, "calledDelay-delay");
+ MockSpanAdapter calledStep = span(spans, "calledStep-setHeader");
+ MockSpanAdapter callerStep = span(spans, "callerStep-setHeader");
+ allDone(spans);
+ assertEquals(caller.getTag("spanid"), send.getTag("parentSpan"));
+ assertEquals(send.getTag("spanid"), called.getTag("parentSpan"));
+ assertEquals(called.getTag("spanid"),
calledDelay.getTag("parentSpan"));
+ assertEquals(called.getTag("spanid"), calledStep.getTag("parentSpan"));
+ assertEquals(caller.getTag("spanid"), callerStep.getTag("parentSpan"));
+ // each span is ended by its own step: the called route and the send
end after the steps they contain
+ endsBeforeStart(calledDelay, calledStep);
+ endsNotBefore(called, calledStep);
+ endsNotBefore(send, called);
+ endsBeforeStart(send, callerStep);
+ endsNotBefore(caller, callerStep);
+ }
+
+ @Test
+ void recipientListToRoutesWithAsyncDelayEndsAllSpans() {
+ List<Span> spans = send("direct:recipients");
+ // the template's send, the route, the recipient list, two sends, two
called routes, two delays and two steps
+ assertEquals(11, spans.size(), "Spans: " + spans);
+ allDone(spans);
+ MockSpanAdapter route = SpanTestSupport.getSpan(spans,
"direct://recipients", Op.EVENT_RECEIVED);
+ MockSpanAdapter recipientList = span(spans,
"recipients-recipientList");
+ assertEquals(route.getTag("spanid"),
recipientList.getTag("parentSpan"));
+ for (String step : List.of("rl1Step-setHeader", "rl2Step-setHeader")) {
+ MockSpanAdapter parent = byId(spans, span(spans,
step).getTag("parentSpan"));
+ assertEquals(Op.EVENT_RECEIVED.toString(), parent.getTag("op"),
+ "The step " + step + " is not a child of its route");
+ }
+ }
+
+ @Test
+ void failureAfterAsyncDelayIsOnlyOnTheFailingStep() {
+ assertThrows(CamelExecutionException.class, () ->
template.sendBody("direct:fail", "my-body"));
+ List<Span> spans = spans();
+ MockSpanAdapter route = SpanTestSupport.getSpan(spans,
"direct://fail", Op.EVENT_RECEIVED);
+ MockSpanAdapter delay = span(spans, "failDelay-delay");
+ MockSpanAdapter failing = span(spans, "failStep-throwException");
+ allDone(spans);
+ assertEquals(route.getTag("spanid"), failing.getTag("parentSpan"));
+ assertEquals("true", failing.getTag(TagConstants.ERROR));
+ assertNotEquals("true", delay.getTag(TagConstants.ERROR));
+ }
+
+ private void checkStepsAfter(String uri, String asyncStep, String prefix) {
+ List<Span> spans = send(uri);
+ MockSpanAdapter route = SpanTestSupport.getSpan(spans,
uri.replace(":", "://"), Op.EVENT_RECEIVED);
+ MockSpanAdapter async = span(spans, asyncStep);
+ MockSpanAdapter step2 = span(spans, prefix + "Step2-setHeader");
+ MockSpanAdapter step3 = span(spans, prefix + "Step3-setHeader");
+ allDone(spans);
+ assertEquals(route.getTag("spanid"), async.getTag("parentSpan"));
+ assertEquals(route.getTag("spanid"), step2.getTag("parentSpan"));
+ assertEquals(route.getTag("spanid"), step3.getTag("parentSpan"));
+ endsBeforeStart(async, step2);
+ }
+
+ private List<Span> send(String uri) {
+ template.sendBody(uri, "my-body");
+ return spans();
+ }
+
+ private List<Span> spans() {
+ Map<String, MockTrace> traces = mockTracer.traces();
+ assertEquals(1, traces.size());
+ return traces.values().iterator().next().spans();
+ }
+
+ private static MockSpanAdapter span(List<Span> spans, String component) {
+ MockSpanAdapter answer = null;
+ for (Span s : spans) {
+ MockSpanAdapter msa = (MockSpanAdapter) s;
+ if (component.equals(msa.getTag(TagConstants.COMPONENT))
+ && Op.EVENT_PROCESS.toString().equals(msa.getTag("op"))) {
+ answer = msa;
+ }
+ }
+ assertNotNull(answer, "No processor span " + component + " in " +
spans);
+ return answer;
+ }
+
+ private static MockSpanAdapter byId(List<Span> spans, String spanId) {
+ for (Span s : spans) {
+ MockSpanAdapter msa = (MockSpanAdapter) s;
+ if (spanId != null && spanId.equals(msa.getTag("spanid"))) {
+ return msa;
+ }
+ }
+ throw new IllegalArgumentException("No span with id " + spanId);
+ }
+
+ private static void allDone(List<Span> spans) {
+ for (Span s : spans) {
+ assertEquals("true", ((MockSpanAdapter) s).getTag("isDone"), "Span
not ended: " + s);
+ }
+ }
+
+ private static void endsBeforeStart(MockSpanAdapter first, MockSpanAdapter
next) {
+ long end = Long.parseLong(first.getTag("endTimestamp"));
+ long start = Long.parseLong(next.getTag("initTimestamp"));
+ assertTrue(end <= start, "The span " + first.tags() + " ends after the
span " + next.tags() + " starts");
+ }
+
+ private static void endsNotBefore(MockSpanAdapter outer, MockSpanAdapter
inner) {
+ long outerEnd = Long.parseLong(outer.getTag("endTimestamp"));
+ long innerEnd = Long.parseLong(inner.getTag("endTimestamp"));
+ assertTrue(outerEnd >= innerEnd, "The span " + outer.tags() + " ends
before the span " + inner.tags());
+ }
+
+ public static class FutureBean {
+ public CompletableFuture<String> call(String body) {
+ // completes on another thread, after the bean has returned
+ return CompletableFuture.supplyAsync(() -> body + "-reply",
+ CompletableFuture.delayedExecutor(10,
TimeUnit.MILLISECONDS));
+ }
+ }
+
+ @Override
+ protected RoutesBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ // a delay is asynchronous by default
+ from("direct:delay").routeId("delayRoute")
+ .delay(10).id("async")
+ .setHeader("step2", constant("2")).id("delayStep2")
+ .setHeader("step3", constant("3")).id("delayStep3");
+
+ from("direct:threads").routeId("threadsRoute")
+ .threads(1).id("threads")
+ .setHeader("step2", constant("2")).id("threadsStep2")
+ .setHeader("step3", constant("3")).id("threadsStep3");
+
+ from("direct:future").routeId("futureRoute")
+ .bean("futureBean", "call").id("future")
+ .setHeader("step2", constant("2")).id("futureStep2")
+ .setHeader("step3", constant("3")).id("futureStep3");
+
+ from("direct:syncdelay").routeId("syncDelayRoute")
+ .delay(10).syncDelayed().id("sync")
+ .setHeader("step2", constant("2")).id("syncStep2")
+ .setHeader("step3", constant("3")).id("syncStep3");
+
+ // the choice is built in two statements: in one chain, end()
after delay() does not end the choice
+ RouteDefinition choiceRoute =
from("direct:choice").routeId("choiceRoute");
+ choiceRoute.choice().id("choice")
+ .when(constant(true))
+ .delay(10).id("innerDelay")
+ .setHeader("inner", constant("x")).id("inner");
+ choiceRoute.setHeader("after", constant("y")).id("after");
+
+ from("direct:caller").routeId("callerRoute")
+ .to("direct:called")
+ .setHeader("caller", constant("x")).id("callerStep");
+
+ from("direct:called").routeId("calledRoute")
+ .delay(10).id("calledDelay")
+ .setHeader("called", constant("y")).id("calledStep");
+
+ from("direct:recipients").routeId("recipientsRoute")
+
.recipientList(constant("direct:rl1,direct:rl2")).id("recipients");
+
+ from("direct:rl1").routeId("rl1Route")
+ .delay(10).id("rl1Delay")
+ .setHeader("rl", constant("1")).id("rl1Step");
+
+ from("direct:rl2").routeId("rl2Route")
+ .delay(10).id("rl2Delay")
+ .setHeader("rl", constant("2")).id("rl2Step");
+
+ from("direct:fail").routeId("failRoute")
+ .delay(10).id("failDelay")
+ .throwException(new
IllegalStateException("fail")).id("failStep");
+ }
+ };
+ }
+
+}
diff --git
a/components/camel-telemetry/src/test/java/org/apache/camel/telemetry/ProcessorSpanBeginFailureTest.java
b/components/camel-telemetry/src/test/java/org/apache/camel/telemetry/ProcessorSpanBeginFailureTest.java
new file mode 100644
index 000000000000..45bac378a938
--- /dev/null
+++
b/components/camel-telemetry/src/test/java/org/apache/camel/telemetry/ProcessorSpanBeginFailureTest.java
@@ -0,0 +1,123 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.camel.telemetry;
+
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.CamelContextAware;
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.telemetry.mock.MockSpanAdapter;
+import org.apache.camel.telemetry.mock.MockTrace;
+import org.apache.camel.telemetry.mock.MockTracer;
+import org.apache.camel.test.junit6.ExchangeTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * When the span of a step cannot be begun, the end of that step does not end
another span (the span on top of the
+ * exchange's span stack is then the span of the route).
+ */
+class ProcessorSpanBeginFailureTest extends ExchangeTestSupport {
+
+ MockTracer mockTracer;
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ CamelContext context = super.createCamelContext();
+ this.mockTracer = new FailingBeginTracer();
+ CamelContextAware.trySetCamelContext(mockTracer, context);
+ mockTracer.init(context);
+ return context;
+ }
+
+ @Test
+ void stepAfterAFailedBeginIsAChildOfTheRoute() {
+ template.sendBody("direct:start", "my-body");
+ Map<String, MockTrace> traces = mockTracer.traces();
+ assertEquals(1, traces.size());
+ List<Span> spans = traces.values().iterator().next().spans();
+ // the template's send, the route, and the step after the failed one
(the failed one has no span)
+ assertEquals(3, spans.size(), "Spans: " + spans);
+ MockSpanAdapter route = SpanTestSupport.getSpan(spans,
"direct://start", Op.EVENT_RECEIVED);
+ MockSpanAdapter after = (MockSpanAdapter) spans.get(2);
+ assertEquals("after-setHeader", after.getTag(TagConstants.COMPONENT));
+ assertEquals(route.getTag("spanid"), after.getTag("parentSpan"));
+ for (Span s : spans) {
+ assertEquals("true", ((MockSpanAdapter) s).getTag("isDone"), "Span
not ended: " + s);
+ }
+ }
+
+ @Override
+ protected RoutesBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ // the failure to begin the span is set on the exchange, and
continued here
+ onException(IllegalStateException.class).continued(true);
+
+ from("direct:start").routeId("start")
+ .setHeader("a", constant("1")).id("boom")
+ .setHeader("b", constant("2")).id("after");
+ }
+ };
+ }
+
+ /**
+ * A tracer whose span creation fails for the step with the id boom.
+ */
+ private static class FailingBeginTracer extends MockTracer {
+ @Override
+ protected void initTracer() {
+ super.initTracer();
+ SpanLifecycleManager delegate = getSpanLifecycleManager();
+ setSpanLifecycleManager(new SpanLifecycleManager() {
+ @Override
+ public Span create(
+ String spanName, String spanKind, Span parent,
SpanContextPropagationExtractor extractor) {
+ if (spanName.startsWith("boom-")) {
+ throw new IllegalStateException("Cannot create the
span " + spanName);
+ }
+ return delegate.create(spanName, spanKind, parent,
extractor);
+ }
+
+ @Override
+ public void activate(Span span) {
+ delegate.activate(span);
+ }
+
+ @Override
+ public void deactivate(Span span) {
+ delegate.deactivate(span);
+ }
+
+ @Override
+ public void close(Span span) {
+ delegate.close(span);
+ }
+
+ @Override
+ public void inject(Span span, SpanContextPropagationInjector
injector, boolean includeTracing) {
+ delegate.inject(span, injector, includeTracing);
+ }
+ });
+ }
+ }
+}
diff --git
a/components/camel-telemetry/src/test/java/org/apache/camel/telemetry/ProcessorSpanEndFailureTest.java
b/components/camel-telemetry/src/test/java/org/apache/camel/telemetry/ProcessorSpanEndFailureTest.java
new file mode 100644
index 000000000000..a11d95dfa2f1
--- /dev/null
+++
b/components/camel-telemetry/src/test/java/org/apache/camel/telemetry/ProcessorSpanEndFailureTest.java
@@ -0,0 +1,106 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.camel.telemetry;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.CamelContextAware;
+import org.apache.camel.Exchange;
+import org.apache.camel.NamedNode;
+import org.apache.camel.Processor;
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.model.ModelCamelContext;
+import org.apache.camel.telemetry.mock.MockTracer;
+import org.apache.camel.test.junit6.ExchangeTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * A failure to end the span of a step does not replace an exception that the
step has already set on the exchange.
+ */
+class ProcessorSpanEndFailureTest extends ExchangeTestSupport {
+
+ MockTracer mockTracer;
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ CamelContext context = super.createCamelContext();
+ this.mockTracer = new FailingEndTracer();
+ CamelContextAware.trySetCamelContext(mockTracer, context);
+ mockTracer.init(context);
+ return context;
+ }
+
+ @Test
+ void endFailureKeepsTheExceptionOfTheStep() {
+ Exchange out = template.send("direct:fails", e ->
e.getMessage().setBody("my-body"));
+ Exception e = out.getException();
+ assertInstanceOf(IllegalArgumentException.class, e, "Exception: " + e);
+ assertEquals("business failure", e.getMessage());
+ }
+
+ @Test
+ void endFailureIsSetWhenTheStepDidNotFail() {
+ Exchange out = template.send("direct:ok", e ->
e.getMessage().setBody("my-body"));
+ Exception e = out.getException();
+ assertInstanceOf(IllegalStateException.class, e, "Exception: " + e);
+ assertEquals("Cannot end the span boomOk-setHeader", e.getMessage());
+ }
+
+ @Test
+ void endFailureKeepsTheExceptionOfTheBlockingStep() throws Exception {
+ // the blocking process(Exchange) of the processor that traces the step
+ NamedNode node = ((ModelCamelContext)
context).getRouteDefinition("fails").getOutputs().get(0);
+ Processor traced = new
TraceProcessorsInterceptStrategy(mockTracer).wrapProcessorInInterceptors(context,
node,
+ e -> {
+ throw new IllegalArgumentException("business failure");
+ }, null);
+ Exception e = assertThrows(Exception.class, () ->
traced.process(createExchangeWithBody("my-body")));
+ assertInstanceOf(IllegalArgumentException.class, e, "Exception: " + e);
+ assertEquals("business failure", e.getMessage());
+ }
+
+ @Override
+ protected RoutesBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:fails").routeId("fails")
+ .throwException(new IllegalArgumentException("business
failure")).id("boomFails");
+
+ from("direct:ok").routeId("ok")
+ .setHeader("a", constant("1")).id("boomOk");
+ }
+ };
+ }
+
+ /**
+ * A tracer that ends the span of a step with an id starting with boom,
then fails.
+ */
+ private static class FailingEndTracer extends MockTracer {
+ @Override
+ protected void endProcessorSpan(Exchange exchange, String
processorName) throws Exception {
+ super.endProcessorSpan(exchange, processorName);
+ if (processorName.startsWith("boom")) {
+ throw new IllegalStateException("Cannot end the span " +
processorName);
+ }
+ }
+ }
+}
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 170f1d8490c1..9becf9752ee5 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -597,6 +597,27 @@ closed that client for every other endpoint.
Camel now closes only the clients it created. A client set with
`amazonKinesisClient` or `amazonKinesisAsyncClient`, on
the component or on an endpoint, is no longer closed when Camel stops: close
it yourself when you no longer need it.
+=== camel-telemetry - the span of an asynchronous step ends when the step is
done
+
+This applies to `camel-opentelemetry2`, `camel-micrometer-observability` and
`camel-telemetry-dev`. The span of a step
+that calls back from another thread (such as `delay`, which is asynchronous by
default, `threads`, `throttle` with
+`asyncDelayed`, or a `bean` that returns a `CompletableFuture`) now ends when
the step is done, and the steps after it
+are children of the span that contains the step (the route span, or the span
of an EIP such as `choice`). Since 4.17
+(since 4.21 with the default options, which trace the core processors) the
following steps were children of the span
+of the asynchronous step, and that span lasted until the end of the route.
Around a called route the spans were ended
+by the wrong events, and with a `recipientList` or `enrich` to such a route
some spans were never ended. In particular:
+
+* The trace tree of such routes changes, and the span of the asynchronous step
is shorter.
+* A failure in a later step no longer marks the span of the asynchronous step
as failed: the error is on the span of
+ the failing step and on the spans that contain it.
+* Steps of an `onException` that run after the asynchronous step, and custom
spans created in a processor after it,
+ are no longer nested under its span.
+* An asynchronous step that fails and is then handled by an `onException` with
`handled(true)` now has its span marked
+ as an error, because the span ends before the error handler runs. A
synchronous step already behaved that way.
+
+This is the tree of Camel 4.16 and older, so there is no option to keep the
4.17 tree. A dashboard that read the time of
+the rest of the route from the span of the asynchronous step can use the route
span instead.
+
=== camel-oaipmh - the CamelOaimphOnlyFirst and CamelOaimphIgnoreSSLWarnings
headers apply to their message only
The `CamelOaimphOnlyFirst` and `CamelOaimphIgnoreSSLWarnings` headers now
apply only to the message that has them.