Skip to content

Commit ff0b674

Browse files
🐛 fix(observability): emit OpenTelemetry span links across async boundaries
Replace `axon.tracing.event-processor.distributed-in-same-trace: true` with `disable-batch-trace: true`. With the batch root disabled, each `StreamingEventProcessor.process(Event)` becomes its own root trace carrying an `OpenTelemetry` `FOLLOWS_FROM` link back to the originating `EventBus.publishEvent(...)` — rendered as a clickable "span in another trace" reference in `Jaeger` and `Kibana APM`, as the Axon reference guide promises. Add `TracingConfigurationTest` that verifies the `propagateContext` wiring by injecting a real `OpenTelemetrySdk` with the `W3CTraceContextPropagator`. Includes a regression guard that documents the upstream `axon-spring-boot-autoconfigure` bug — the test will start failing once `OpenTelemetryAutoConfiguration` upstream is fixed, signalling that our explicit propagator wiring can be simplified.
1 parent 0a7f081 commit ff0b674

2 files changed

Lines changed: 100 additions & 4 deletions

File tree

src/main/resources/application.yaml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,11 @@ axon:
7171
# All Axon attribute providers stay at their defaults (true)
7272
show-event-sourcing-handlers: false
7373
event-processor:
74-
# Nest async (StreamingEventProcessor / pooled) event handler spans into the trace
75-
# that published the event, when handled within the time limit below. Off by default.
76-
distributed-in-same-trace: true
77-
distributed-in-same-trace-time-limit: 2m
74+
# Disable the StreamingEventProcessor.batch root span. Each event handler becomes
75+
# its own root trace with an OpenTelemetry LINK back to the publish-event span —
76+
# so Jaeger / Kibana APM render clickable "linked traces" for the cause/effect pair
77+
# across async boundaries. Off by default in Axon (batch trace = no links).
78+
disable-batch-trace: true
7879
# update-check:
7980
# disabled: true
8081

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package com.dddheroes.heroesofddd.shared.infrastructure;
2+
3+
import io.opentelemetry.api.OpenTelemetry;
4+
import io.opentelemetry.api.trace.Span;
5+
import io.opentelemetry.api.trace.Tracer;
6+
import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator;
7+
import io.opentelemetry.context.Scope;
8+
import io.opentelemetry.context.propagation.ContextPropagators;
9+
import io.opentelemetry.sdk.OpenTelemetrySdk;
10+
import io.opentelemetry.sdk.trace.SdkTracerProvider;
11+
import org.axonframework.eventhandling.EventMessage;
12+
import org.axonframework.eventhandling.GenericEventMessage;
13+
import org.axonframework.tracing.opentelemetry.OpenTelemetrySpanFactory;
14+
import org.junit.jupiter.api.DisplayName;
15+
import org.junit.jupiter.api.Nested;
16+
import org.junit.jupiter.api.Test;
17+
18+
import static org.assertj.core.api.Assertions.assertThat;
19+
20+
/**
21+
* Verifies that {@link TracingConfiguration} wires {@link OpenTelemetrySpanFactory} with the
22+
* Spring-managed {@link OpenTelemetry} (and NOT the no-op {@code GlobalOpenTelemetry}).
23+
* <p>
24+
* Without an explicit {@code contextPropagators(...)} the builder falls back to
25+
* {@code GlobalOpenTelemetry.getPropagators()}, which in a Spring Boot 3 + Micrometer Tracing
26+
* setup is a no-op — silently breaking W3C trace context propagation across Axon async boundaries
27+
* (event processors, deadlines, Axon Server gRPC). See {@code OpenTelemetryAutoConfiguration}
28+
* in axon-spring-boot-autoconfigure (4.13.x) for the same upstream bug.
29+
*/
30+
@DisplayName("TracingConfiguration")
31+
class TracingConfigurationTest {
32+
33+
@Nested
34+
@DisplayName("propagateContext via the configured OpenTelemetrySpanFactory")
35+
class PropagateContext {
36+
37+
@Test
38+
@DisplayName("injects W3C traceparent into message metadata when configured with a Spring-managed OpenTelemetry")
39+
void shouldInjectTraceparentWhenConfiguredCorrectly() {
40+
// given — Spring-managed OpenTelemetry with a real W3C propagator
41+
OpenTelemetry openTelemetry = realOpenTelemetry();
42+
OpenTelemetrySpanFactory spanFactory = new TracingConfiguration().openTelemetrySpanFactory(openTelemetry);
43+
44+
// when — propagate the current span context into an Axon message
45+
Tracer tracer = openTelemetry.getTracer("test");
46+
Span currentSpan = tracer.spanBuilder("root").startSpan();
47+
EventMessage<String> propagated;
48+
try (Scope ignored = currentSpan.makeCurrent()) {
49+
propagated = spanFactory.propagateContext(GenericEventMessage.asEventMessage("payload"));
50+
} finally {
51+
currentSpan.end();
52+
}
53+
54+
// then — the W3C traceparent header is present in the message metadata
55+
assertThat(propagated.getMetaData())
56+
.as("W3C trace context must be injected into message metadata so async handlers can extract it")
57+
.containsKey("traceparent");
58+
}
59+
60+
@Test
61+
@DisplayName("regression guard: factory built WITHOUT explicit propagator (the upstream Axon bug) silently drops the context")
62+
void shouldDocumentTheBugWhenPropagatorIsNotConfigured() {
63+
// given — the buggy default: OpenTelemetrySpanFactory.builder().build() without
64+
// passing the Spring-managed OpenTelemetry. This is what
65+
// axon-spring-boot-autoconfigure's OpenTelemetryAutoConfiguration does today.
66+
OpenTelemetry openTelemetry = realOpenTelemetry();
67+
OpenTelemetrySpanFactory buggyFactory = OpenTelemetrySpanFactory.builder().build();
68+
69+
// when — propagate the current span context into an Axon message
70+
Tracer tracer = openTelemetry.getTracer("test");
71+
Span currentSpan = tracer.spanBuilder("root").startSpan();
72+
EventMessage<String> propagated;
73+
try (Scope ignored = currentSpan.makeCurrent()) {
74+
propagated = buggyFactory.propagateContext(GenericEventMessage.asEventMessage("payload"));
75+
} finally {
76+
currentSpan.end();
77+
}
78+
79+
// then — no traceparent injected: the factory used the no-op GlobalOpenTelemetry
80+
// propagator instead of the Spring-managed one. If this assertion ever starts
81+
// FAILING upstream, it means Axon fixed the bug and our explicit wiring becomes
82+
// optional (still harmless).
83+
assertThat(propagated.getMetaData())
84+
.as("If this test breaks, the upstream Axon bug is fixed — our workaround can be simplified")
85+
.doesNotContainKey("traceparent");
86+
}
87+
}
88+
89+
private static OpenTelemetry realOpenTelemetry() {
90+
return OpenTelemetrySdk.builder()
91+
.setTracerProvider(SdkTracerProvider.builder().build())
92+
.setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance()))
93+
.build();
94+
}
95+
}

0 commit comments

Comments
 (0)