diff --git a/components/camel-micrometer-observability/src/main/java/org/apache/camel/micrometer/observability/MicrometerObservabilitySpanAdapter.java b/components/camel-micrometer-observability/src/main/java/org/apache/camel/micrometer/observability/MicrometerObservabilitySpanAdapter.java index 44bdff3e920d2..40548dec88044 100644 --- a/components/camel-micrometer-observability/src/main/java/org/apache/camel/micrometer/observability/MicrometerObservabilitySpanAdapter.java +++ b/components/camel-micrometer-observability/src/main/java/org/apache/camel/micrometer/observability/MicrometerObservabilitySpanAdapter.java @@ -18,6 +18,7 @@ import java.util.Map; +import io.micrometer.tracing.Tracer; import org.apache.camel.telemetry.Span; public class MicrometerObservabilitySpanAdapter implements Span { @@ -25,9 +26,12 @@ public class MicrometerObservabilitySpanAdapter implements Span { private static final String DEFAULT_EVENT_NAME = "log"; private final io.micrometer.tracing.Span span; + private final Tracer tracer; + private Tracer.SpanInScope spanInScope; - public MicrometerObservabilitySpanAdapter(io.micrometer.tracing.Span span) { + public MicrometerObservabilitySpanAdapter(io.micrometer.tracing.Span span, Tracer tracer) { this.span = span; + this.tracer = tracer; } @Override @@ -64,14 +68,18 @@ protected io.micrometer.tracing.Span getSpan() { protected void activate() { this.span.start(); - } - - protected void close() { - this.span.end(); + this.spanInScope = this.tracer.withSpan(this.span); } protected void deactivate() { + if (this.spanInScope != null) { + this.spanInScope.close(); + this.spanInScope = null; + } + } + protected void close() { + this.span.end(); } @Override diff --git a/components/camel-micrometer-observability/src/main/java/org/apache/camel/micrometer/observability/MicrometerObservabilityTracer.java b/components/camel-micrometer-observability/src/main/java/org/apache/camel/micrometer/observability/MicrometerObservabilityTracer.java index 76b18dd6fb4cc..4f8515fc37a5b 100644 --- a/components/camel-micrometer-observability/src/main/java/org/apache/camel/micrometer/observability/MicrometerObservabilityTracer.java +++ b/components/camel-micrometer-observability/src/main/java/org/apache/camel/micrometer/observability/MicrometerObservabilityTracer.java @@ -136,11 +136,27 @@ public Span create(String spanName, Span parent, SpanContextPropagationExtractor return extractor.get(key) == null ? null : (String) extractor.get(key); }); + // If no trace headers were found in the carrier, any parent context in + // the builder came from the thread-local scope (e.g., Context.current() + // for OTel). This can be stale when async processing moves span lifecycle + // to a different thread, leaving the original thread's scope un-cleaned. + // Force a root span in that case to prevent trace contamination. + boolean hasTraceHeaders = false; + for (String field : propagator.fields()) { + if (extractor.get(field) != null) { + hasTraceHeaders = true; + break; + } + } + if (!hasTraceHeaders) { + builder.setNoParent(); + } + span = builder.start(); } span.name(spanName); - return new MicrometerObservabilitySpanAdapter(span); + return new MicrometerObservabilitySpanAdapter(span, tracer); } @Override diff --git a/components/camel-micrometer-observability/src/test/java/org/apache/camel/micrometer/observability/MicrometerObservabilityTracerPropagationTestSupport.java b/components/camel-micrometer-observability/src/test/java/org/apache/camel/micrometer/observability/MicrometerObservabilityTracerPropagationTestSupport.java index c39c09f762ec9..8996a363d4b9f 100644 --- a/components/camel-micrometer-observability/src/test/java/org/apache/camel/micrometer/observability/MicrometerObservabilityTracerPropagationTestSupport.java +++ b/components/camel-micrometer-observability/src/test/java/org/apache/camel/micrometer/observability/MicrometerObservabilityTracerPropagationTestSupport.java @@ -18,10 +18,12 @@ import java.util.List; +import io.micrometer.tracing.Tracer; import io.micrometer.tracing.otel.bridge.OtelCurrentTraceContext; import io.micrometer.tracing.otel.bridge.OtelPropagator; import io.micrometer.tracing.otel.bridge.OtelTracer; import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.context.Context; import io.opentelemetry.context.propagation.ContextPropagators; import io.opentelemetry.sdk.trace.data.SpanData; import org.apache.camel.CamelContext; @@ -37,9 +39,15 @@ public class MicrometerObservabilityTracerPropagationTestSupport extends Exchang protected CamelOpenTelemetryExtension otelExtension = CamelOpenTelemetryExtension.create(); protected MicrometerObservabilityTracer tst = new MicrometerObservabilityTracer(); + protected Tracer micrometerTracer; @Override protected CamelContext createCamelContext() throws Exception { + // Clear any stale OTel context left by previous tests (e.g., from async thread + // handoff where deactivate() ran on a different thread). OtelPropagator.extract() + // reads Context.current() directly, so stale spans would become unwanted parents. + Context.root().makeCurrent(); + CamelContext context = super.createCamelContext(); ContextPropagators propagators = otelExtension.getPropagators(); @@ -48,7 +56,7 @@ protected CamelContext createCamelContext() throws Exception { OtelPropagator otelPropagator = new OtelPropagator(propagators, otelTracer); OtelCurrentTraceContext currentTraceContext = new OtelCurrentTraceContext(); // We must convert the Otel Tracer into a micrometer Tracer - io.micrometer.tracing.Tracer micrometerTracer = new OtelTracer( + micrometerTracer = new OtelTracer( otelTracer, currentTraceContext, null); diff --git a/components/camel-micrometer-observability/src/test/java/org/apache/camel/micrometer/observability/SpanScopeTest.java b/components/camel-micrometer-observability/src/test/java/org/apache/camel/micrometer/observability/SpanScopeTest.java new file mode 100644 index 0000000000000..8854bedfc8056 --- /dev/null +++ b/components/camel-micrometer-observability/src/test/java/org/apache/camel/micrometer/observability/SpanScopeTest.java @@ -0,0 +1,97 @@ +/* + * 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 java.util.concurrent.atomic.AtomicReference; + +import io.opentelemetry.sdk.trace.data.SpanData; +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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Verifies that spans are properly put in scope during route execution. This is critical for routes triggered by + * consumers that don't have framework-level tracing (e.g., JMS), where Camel must manage the span scope itself. + * + * Without proper scope management, {@code tracer.currentSpan()} returns null during route execution, which prevents the + * trace from being exported and for downstream instrumentation from attaching to the Camel trace. + */ +public class SpanScopeTest extends MicrometerObservabilityTracerPropagationTestSupport { + + private final AtomicReference capturedCurrentSpan = new AtomicReference<>(); + + @Test + void testSpanIsInScopeDuringRouteExecution() { + template.sendBody("direct:start", "Test Message"); + + // The processor captured tracer.currentSpan() during route execution. + io.micrometer.tracing.Span current = capturedCurrentSpan.get(); + assertNotNull(current); + } + + @Test + void testCapturedSpanMatchesTraceId() { + template.sendBody("direct:start", "Test Message"); + + io.micrometer.tracing.Span current = capturedCurrentSpan.get(); + assertNotNull(current); + + // The captured current span should belong to the same trace as the recorded spans + Map traces = otelExtension.getTraces(); + assertEquals(1, traces.size()); + String expectedTraceId = traces.keySet().iterator().next(); + List spans = traces.get(expectedTraceId).getSpans(); + + SpanData receivedSpan = getSpan(spans, "direct://start", Op.EVENT_RECEIVED); + assertEquals(expectedTraceId, current.context().traceId()); + assertEquals(receivedSpan.getSpanId(), current.context().spanId()); + } + + @Test + void testSpanScopeIsCleanedUpAfterRouteExecution() { + template.sendBody("direct:start", "Test Message"); + + // After the route completes, the scope should be closed and + // tracer.currentSpan() should return null + io.micrometer.tracing.Span afterRoute = micrometerTracer.currentSpan(); + assertNull(afterRoute); + } + + @Override + protected RoutesBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + from("direct:start") + .routeId("start") + .process(exchange -> { + capturedCurrentSpan.set(micrometerTracer.currentSpan()); + }) + .to("log:info"); + } + }; + } + +}