38
38
import io .micrometer .observation .Observation ;
39
39
import io .micrometer .observation .ObservationHandler ;
40
40
import io .micrometer .observation .ObservationRegistry ;
41
- import io .micrometer .observation .tck .TestObservationRegistry ;
42
41
import io .micrometer .tracing .Span ;
43
42
import io .micrometer .tracing .TraceContext ;
44
43
import io .micrometer .tracing .Tracer ;
45
44
import io .micrometer .tracing .handler .DefaultTracingObservationHandler ;
46
45
import io .micrometer .tracing .handler .PropagatingReceiverTracingObservationHandler ;
47
46
import io .micrometer .tracing .handler .PropagatingSenderTracingObservationHandler ;
47
+ import io .micrometer .tracing .handler .TracingAwareMeterObservationHandler ;
48
48
import io .micrometer .tracing .propagation .Propagator ;
49
49
import io .micrometer .tracing .test .simple .SimpleSpan ;
50
+ import io .micrometer .tracing .test .simple .SimpleTraceContext ;
50
51
import io .micrometer .tracing .test .simple .SimpleTracer ;
51
52
import org .apache .kafka .clients .admin .AdminClientConfig ;
52
53
import org .apache .kafka .clients .consumer .Consumer ;
70
71
import org .springframework .context .annotation .Configuration ;
71
72
import org .springframework .context .annotation .Primary ;
72
73
import org .springframework .kafka .KafkaException ;
74
+ import org .springframework .kafka .annotation .DltHandler ;
73
75
import org .springframework .kafka .annotation .EnableKafka ;
74
76
import org .springframework .kafka .annotation .KafkaListener ;
77
+ import org .springframework .kafka .annotation .RetryableTopic ;
75
78
import org .springframework .kafka .config .ConcurrentKafkaListenerContainerFactory ;
76
79
import org .springframework .kafka .config .KafkaListenerEndpointRegistry ;
77
80
import org .springframework .kafka .core .ConsumerFactory ;
80
83
import org .springframework .kafka .core .KafkaAdmin ;
81
84
import org .springframework .kafka .core .KafkaTemplate ;
82
85
import org .springframework .kafka .core .ProducerFactory ;
86
+ import org .springframework .kafka .listener .ContainerProperties ;
83
87
import org .springframework .kafka .listener .MessageListenerContainer ;
84
88
import org .springframework .kafka .listener .RecordInterceptor ;
85
89
import org .springframework .kafka .requestreply .ReplyingKafkaTemplate ;
90
94
import org .springframework .kafka .test .context .EmbeddedKafka ;
91
95
import org .springframework .kafka .test .utils .KafkaTestUtils ;
92
96
import org .springframework .messaging .handler .annotation .SendTo ;
97
+ import org .springframework .retry .annotation .Backoff ;
98
+ import org .springframework .scheduling .TaskScheduler ;
99
+ import org .springframework .scheduling .concurrent .ThreadPoolTaskScheduler ;
93
100
import org .springframework .test .annotation .DirtiesContext ;
94
101
import org .springframework .test .context .junit .jupiter .SpringJUnitConfig ;
95
102
import org .springframework .util .StringUtils ;
113
120
@ EmbeddedKafka (topics = {ObservationTests .OBSERVATION_TEST_1 , ObservationTests .OBSERVATION_TEST_2 ,
114
121
ObservationTests .OBSERVATION_TEST_3 , ObservationTests .OBSERVATION_TEST_4 , ObservationTests .OBSERVATION_REPLY ,
115
122
ObservationTests .OBSERVATION_RUNTIME_EXCEPTION , ObservationTests .OBSERVATION_ERROR ,
116
- ObservationTests .OBSERVATION_TRACEPARENT_DUPLICATE }, partitions = 1 )
123
+ ObservationTests .OBSERVATION_TRACEPARENT_DUPLICATE , ObservationTests .OBSERVATION_ASYNC_FAILURE_TEST ,
124
+ ObservationTests .OBSERVATION_ASYNC_FAILURE_WITH_RETRY_TEST }, partitions = 1 )
117
125
@ DirtiesContext
118
126
public class ObservationTests {
119
127
@@ -137,6 +145,55 @@ public class ObservationTests {
137
145
138
146
public final static String OBSERVATION_TRACEPARENT_DUPLICATE = "observation.traceparent.duplicate" ;
139
147
148
+ public final static String OBSERVATION_ASYNC_FAILURE_TEST = "observation.async.failure.test" ;
149
+
150
+ public final static String OBSERVATION_ASYNC_FAILURE_WITH_RETRY_TEST = "observation.async.failure.retry.test" ;
151
+
152
+ @ Test
153
+ void asyncRetryScopePropagation (@ Autowired AsyncFailureListener asyncFailureListener ,
154
+ @ Autowired KafkaTemplate <Integer , String > template ,
155
+ @ Autowired SimpleTracer tracer ,
156
+ @ Autowired ObservationRegistry observationRegistry ) throws InterruptedException {
157
+
158
+ // Clear any previous spans
159
+ tracer .getSpans ().clear ();
160
+
161
+ // Create an observation scope to ensure we have a proper trace context
162
+ var testObservation = Observation .createNotStarted ("test.message.send" , observationRegistry );
163
+
164
+ // Send a message within the observation scope to ensure trace context is propagated
165
+ testObservation .observe (() -> {
166
+ try {
167
+ template .send (OBSERVATION_ASYNC_FAILURE_TEST , "trigger-async-failure" ).get (5 , TimeUnit .SECONDS );
168
+ }
169
+ catch (Exception e ) {
170
+ throw new RuntimeException ("Failed to send message" , e );
171
+ }
172
+ });
173
+
174
+ // Wait for the listener to process the message (initial + retry + DLT = 3 invocations)
175
+ assertThat (asyncFailureListener .asyncFailureLatch .await (100000 , TimeUnit .SECONDS )).isTrue ();
176
+
177
+ // Verify that the captured spans from the listener contexts are all part of the same trace
178
+ // This demonstrates that the tracing context propagates correctly through the retry mechanism
179
+ Deque <SimpleSpan > spans = tracer .getSpans ();
180
+ assertThat (spans ).hasSizeGreaterThanOrEqualTo (4 ); // template + listener + retry + DLT spans
181
+
182
+ // Verify that spans were captured for each phase and belong to the same trace
183
+ assertThat (asyncFailureListener .capturedSpanInListener ).isNotNull ();
184
+ assertThat (asyncFailureListener .capturedSpanInRetry ).isNotNull ();
185
+ assertThat (asyncFailureListener .capturedSpanInDlt ).isNotNull ();
186
+
187
+ // All spans should have the same trace ID, demonstrating trace continuity
188
+ var originalTraceId = asyncFailureListener .capturedSpanInListener .getTraceId ();
189
+ assertThat (originalTraceId ).isNotBlank ();
190
+ assertThat (asyncFailureListener .capturedSpanInRetry .getTraceId ()).isEqualTo (originalTraceId );
191
+ assertThat (asyncFailureListener .capturedSpanInDlt .getTraceId ()).isEqualTo (originalTraceId );
192
+
193
+ // Clear any previous spans
194
+ tracer .getSpans ().clear ();
195
+ }
196
+
140
197
@ Test
141
198
void endToEnd (@ Autowired Listener listener , @ Autowired KafkaTemplate <Integer , String > template ,
142
199
@ Autowired SimpleTracer tracer , @ Autowired KafkaListenerEndpointRegistry rler ,
@@ -628,6 +685,11 @@ ConcurrentKafkaListenerContainerFactory<Integer, String> kafkaListenerContainerF
628
685
if (container .getListenerId ().equals ("obs3" )) {
629
686
container .setKafkaAdmin (this .mockAdmin );
630
687
}
688
+ if (container .getListenerId ().contains ("asyncFailure" )) {
689
+ // Enable async acks to trigger async failure handling
690
+ container .getContainerProperties ().setAsyncAcks (true );
691
+ container .getContainerProperties ().setAckMode (ContainerProperties .AckMode .MANUAL );
692
+ }
631
693
if (container .getListenerId ().equals ("obs4" )) {
632
694
container .setRecordInterceptor (new RecordInterceptor <>() {
633
695
@@ -662,17 +724,17 @@ MeterRegistry meterRegistry() {
662
724
663
725
@ Bean
664
726
ObservationRegistry observationRegistry (Tracer tracer , Propagator propagator , MeterRegistry meterRegistry ) {
665
- TestObservationRegistry observationRegistry = TestObservationRegistry .create ();
727
+ var observationRegistry = ObservationRegistry .create ();
666
728
observationRegistry .observationConfig ().observationHandler (
667
729
// Composite will pick the first matching handler
668
730
new ObservationHandler .FirstMatchingCompositeObservationHandler (
669
- // This is responsible for creating a child span on the sender side
670
- new PropagatingSenderTracingObservationHandler <>(tracer , propagator ),
671
731
// This is responsible for creating a span on the receiver side
672
732
new PropagatingReceiverTracingObservationHandler <>(tracer , propagator ),
733
+ // This is responsible for creating a child span on the sender side
734
+ new PropagatingSenderTracingObservationHandler <>(tracer , propagator ),
673
735
// This is responsible for creating a default span
674
736
new DefaultTracingObservationHandler (tracer )))
675
- .observationHandler (new DefaultMeterObservationHandler (meterRegistry ));
737
+ .observationHandler (new TracingAwareMeterObservationHandler <>( new DefaultMeterObservationHandler (meterRegistry ), tracer ));
676
738
return observationRegistry ;
677
739
}
678
740
@@ -683,29 +745,41 @@ Propagator propagator(Tracer tracer) {
683
745
// List of headers required for tracing propagation
684
746
@ Override
685
747
public List <String > fields () {
686
- return Arrays .asList ("foo" , "bar" );
748
+ return Arrays .asList ("traceId" , "spanId" , " foo" , "bar" );
687
749
}
688
750
689
751
// This is called on the producer side when the message is being sent
690
- // Normally we would pass information from tracing context - for tests we don't need to
691
752
@ Override
692
753
public <C > void inject (TraceContext context , @ Nullable C carrier , Setter <C > setter ) {
693
754
setter .set (carrier , "foo" , "some foo value" );
694
755
setter .set (carrier , "bar" , "some bar value" );
695
756
757
+ setter .set (carrier , "traceId" , context .traceId ());
758
+ setter .set (carrier , "spanId" , context .spanId ());
759
+
696
760
// Add a traceparent header to simulate W3C trace context
697
761
setter .set (carrier , "traceparent" , "traceparent-from-propagator" );
698
762
}
699
763
700
764
// This is called on the consumer side when the message is consumed
701
- // Normally we would use tools like Extractor from tracing but for tests we are just manually creating a span
702
765
@ Override
703
766
public <C > Span .Builder extract (C carrier , Getter <C > getter ) {
704
767
String foo = getter .get (carrier , "foo" );
705
768
String bar = getter .get (carrier , "bar" );
706
- return tracer .spanBuilder ()
769
+
770
+ var traceId = getter .get (carrier , "traceId" );
771
+ var spanId = getter .get (carrier , "spanId" );
772
+
773
+ Span .Builder spanBuilder = tracer .spanBuilder ()
707
774
.tag ("foo" , foo )
708
775
.tag ("bar" , bar );
776
+
777
+ var traceContext = new SimpleTraceContext ();
778
+ traceContext .setTraceId (traceId );
779
+ traceContext .setSpanId (spanId );
780
+ spanBuilder = spanBuilder .setParent (traceContext );
781
+
782
+ return spanBuilder ;
709
783
}
710
784
};
711
785
}
@@ -720,6 +794,15 @@ ExceptionListener exceptionListener() {
720
794
return new ExceptionListener ();
721
795
}
722
796
797
+ @ Bean
798
+ AsyncFailureListener asyncFailureListener (SimpleTracer tracer ) {
799
+ return new AsyncFailureListener (tracer );
800
+ }
801
+
802
+ @ Bean
803
+ public TaskScheduler taskExecutor () {
804
+ return new ThreadPoolTaskScheduler ();
805
+ }
723
806
}
724
807
725
808
public static class Listener {
@@ -801,4 +884,54 @@ Mono<Void> receive1(ConsumerRecord<Object, Object> record) {
801
884
802
885
}
803
886
887
+ public static class AsyncFailureListener {
888
+
889
+ final CountDownLatch asyncFailureLatch = new CountDownLatch (3 );
890
+
891
+ volatile @ Nullable SimpleSpan capturedSpanInListener ;
892
+
893
+ volatile @ Nullable SimpleSpan capturedSpanInRetry ;
894
+
895
+ volatile @ Nullable SimpleSpan capturedSpanInDlt ;
896
+
897
+ private final SimpleTracer tracer ;
898
+
899
+ public AsyncFailureListener (SimpleTracer tracer ) {
900
+ this .tracer = tracer ;
901
+ }
902
+
903
+ @ RetryableTopic (
904
+ attempts = "2" ,
905
+ backoff = @ Backoff (delay = 1000 )
906
+ )
907
+ @ KafkaListener (id = "asyncFailure" , topics = OBSERVATION_ASYNC_FAILURE_TEST )
908
+ CompletableFuture <Void > handleAsync (ConsumerRecord <Integer , String > record ) {
909
+
910
+ // Use topic name to distinguish between original and retry calls
911
+ String topicName = record .topic ();
912
+
913
+ if (topicName .equals (OBSERVATION_ASYNC_FAILURE_TEST )) {
914
+ // This is the original call
915
+ this .capturedSpanInListener = this .tracer .currentSpan ();
916
+ }
917
+ else {
918
+ // This is a retry call (topic name will be different for retry topics)
919
+ this .capturedSpanInRetry = this .tracer .currentSpan ();
920
+ }
921
+
922
+ this .asyncFailureLatch .countDown ();
923
+
924
+ // Return a failed CompletableFuture to trigger async failure handling
925
+ return CompletableFuture .supplyAsync (() -> {
926
+ throw new RuntimeException ("Async failure for observation test" );
927
+ });
928
+ }
929
+
930
+ @ DltHandler
931
+ void handleDlt (ConsumerRecord <Integer , String > record , Exception exception ) {
932
+ this .capturedSpanInDlt = this .tracer .currentSpan ();
933
+ this .asyncFailureLatch .countDown ();
934
+ }
935
+ }
936
+
804
937
}
0 commit comments