Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,20 @@

import java.util.Map;

import io.micrometer.tracing.Tracer;
import org.apache.camel.telemetry.Span;

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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<io.micrometer.tracing.Span> 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<String, OtelTrace> traces = otelExtension.getTraces();
assertEquals(1, traces.size());
String expectedTraceId = traces.keySet().iterator().next();
List<SpanData> 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");
}
};
}

}
Loading