Skip to main content

camel_core/shared/observability/adapters/
tracer.rs

1use std::future::Future;
2use std::pin::Pin;
3use std::sync::Arc;
4use std::task::{Context, Poll};
5use std::time::Instant;
6
7use opentelemetry::trace::{SpanKind, Status, TraceContextExt, Tracer};
8use opentelemetry::{Context as OtelContext, KeyValue, global};
9use tower::Service;
10use tracing::Instrument;
11
12use crate::shared::observability::domain::DetailLevel;
13use camel_api::metrics::MetricsCollector;
14use camel_api::{Body, BoxProcessor, CamelError, Exchange};
15
16/// RAII guard that ensures an OTel span is ended when dropped.
17///
18/// This prevents span leaks if the inner processor panics or returns early.
19struct SpanEndGuard(OtelContext);
20
21impl Drop for SpanEndGuard {
22    fn drop(&mut self) {
23        self.0.span().end();
24    }
25}
26
27/// Returns a human-readable name for the body type variant.
28fn body_type_name(body: &Body) -> &'static str {
29    match body {
30        Body::Empty => "empty",
31        Body::Bytes(_) => "bytes",
32        Body::Text(_) => "text",
33        Body::Json(_) => "json",
34        Body::Xml(_) => "xml",
35        Body::Stream(_) => "stream",
36        _ => "unknown",
37    }
38}
39
40/// A processor wrapper that emits tracing spans for each step.
41///
42/// This processor wraps another processor and adds distributed tracing by:
43/// 1. Starting a native OpenTelemetry span for each exchange
44/// 2. Propagating the OTel context through `exchange.otel_context`
45/// 3. Recording errors and status on the span
46///
47/// When no OTel provider is configured (noop provider), spans are no-ops with minimal overhead.
48pub struct TracingProcessor {
49    inner: BoxProcessor,
50    route_id: String,
51    step_id: String,
52    span_name: String,
53    step_index: usize,
54    detail_level: DetailLevel,
55    metrics: Option<Arc<dyn MetricsCollector>>,
56}
57
58impl TracingProcessor {
59    /// Wrap a processor with tracing.
60    pub fn new(
61        inner: BoxProcessor,
62        route_id: String,
63        step_index: usize,
64        detail_level: DetailLevel,
65        metrics: Option<Arc<dyn MetricsCollector>>,
66    ) -> Self {
67        let step_id = format!("step-{}", step_index);
68        let span_name = format!("{route_id}:{step_id}");
69        Self {
70            inner,
71            route_id,
72            step_id,
73            span_name,
74            step_index,
75            detail_level,
76            metrics,
77        }
78    }
79}
80
81impl Service<Exchange> for TracingProcessor {
82    type Response = Exchange;
83    type Error = CamelError;
84    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
85
86    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
87        self.inner.poll_ready(cx)
88    }
89
90    fn call(&mut self, mut exchange: Exchange) -> Self::Future {
91        let start = Instant::now();
92        let span_name = self.span_name.clone();
93
94        // Get the global tracer (noop if no provider is configured)
95        let tracer = global::tracer("camel-core");
96
97        // Extract parent context from exchange.otel_context
98        let parent_cx = exchange.otel_context.clone();
99
100        // Build span attributes
101        let mut attributes = [
102            KeyValue::new("messaging.system", "camel"),
103            KeyValue::new(
104                "correlation_id",
105                capped_correlation_id(exchange.correlation_id()).to_string(),
106            ),
107            KeyValue::new("route_id", self.route_id.clone()),
108            KeyValue::new("step_id", self.step_id.clone()),
109            KeyValue::new("step_index", self.step_index as i64),
110            KeyValue::new("headers_count", 0i64),
111            KeyValue::new("body_type", ""),
112            KeyValue::new("has_error", false),
113        ];
114        let mut attr_count = 5;
115
116        if self.detail_level >= DetailLevel::Medium {
117            attributes[5] = KeyValue::new("headers_count", exchange.input.headers.len() as i64);
118            attributes[6] = KeyValue::new("body_type", body_type_name(&exchange.input.body));
119            attributes[7] = KeyValue::new("has_error", exchange.has_error());
120            attr_count = 8;
121        }
122
123        // Start a new span as a child of the parent context
124        let span = tracer
125            .span_builder(span_name)
126            .with_kind(SpanKind::Internal)
127            .with_attributes(attributes[..attr_count].iter().cloned())
128            .start_with_context(&tracer, &parent_cx);
129
130        // Create new context with this span as the active span
131        let cx = OtelContext::current_with_span(span);
132
133        // Store back into exchange so downstream processors inherit this context
134        exchange.otel_context = cx.clone();
135
136        // Also create a tracing span for local dev logging
137        let tracing_span = tracing::info_span!(
138            target: "camel_tracer",
139            "step",
140            correlation_id = %exchange.correlation_id(),
141            route_id = %self.route_id,
142            step_id = %self.step_id,
143            step_index = self.step_index,
144            duration_ms = tracing::field::Empty,
145            status = tracing::field::Empty,
146            headers_count = tracing::field::Empty,
147            body_type = tracing::field::Empty,
148            has_error = tracing::field::Empty,
149            output_body_type = tracing::field::Empty,
150            header_0 = tracing::field::Empty,
151            header_1 = tracing::field::Empty,
152            header_2 = tracing::field::Empty,
153            error = tracing::field::Empty,
154            error_type = tracing::field::Empty,
155        );
156
157        if self.detail_level >= DetailLevel::Medium {
158            tracing_span.record("headers_count", exchange.input.headers.len() as u64);
159            tracing_span.record("body_type", body_type_name(&exchange.input.body));
160            tracing_span.record("has_error", exchange.has_error());
161        }
162
163        if self.detail_level >= DetailLevel::Full {
164            let headers: Vec<_> = exchange.input.headers.iter().take(3).collect();
165            if let Some((k, v)) = headers.first() {
166                tracing_span.record("header_0", format!("{k}={v:?}"));
167            }
168            if let Some((k, v)) = headers.get(1) {
169                tracing_span.record("header_1", format!("{k}={v:?}"));
170            }
171            if let Some((k, v)) = headers.get(2) {
172                tracing_span.record("header_2", format!("{k}={v:?}"));
173            }
174        }
175
176        // Consume the ORIGINAL inner that `poll_ready` readied: its
177        // reservations (e.g. DirectProducer's pending semaphore permit)
178        // belong to that instance, so re-readying a clone would drop them.
179        // The fresh clone stays in `self.inner` as the unreadied placeholder
180        // for the next ready/call cycle.
181        let fresh = self.inner.clone();
182        let mut inner = std::mem::replace(&mut self.inner, fresh);
183        let detail_level = self.detail_level.clone();
184        let metrics = self.metrics.clone();
185        let route_id = self.route_id.clone();
186
187        Box::pin(
188            async move {
189                // Note: ContextGuard is not Send (it uses thread-local storage), so we cannot
190                // hold it across await points in an async fn. Instead, we propagate the OTel
191                // context through exchange.otel_context, which is Send + Sync.
192
193                // Create guard to ensure span is ended even on panic
194                let _guard = SpanEndGuard(cx.clone());
195
196                let result = inner.call(exchange).await;
197
198                let duration = start.elapsed();
199                let duration_ms = duration.as_millis() as u64;
200                tracing::Span::current().record("duration_ms", duration_ms);
201
202                // Record duration on OTel span
203                cx.span()
204                    .set_attribute(KeyValue::new("duration_ms", duration_ms as i64));
205
206                // Record metrics if collector is present
207                if let Some(ref metrics) = metrics {
208                    metrics.record_exchange_duration(&route_id, duration);
209                    metrics.increment_exchanges(&route_id);
210
211                    if let Err(e) = &result {
212                        metrics.increment_errors(&route_id, e.classify());
213                    }
214                }
215
216                match &result {
217                    Ok(ex) => {
218                        tracing::Span::current().record("status", "success");
219                        cx.span().set_status(Status::Ok);
220
221                        if detail_level >= DetailLevel::Medium {
222                            tracing::Span::current()
223                                .record("output_body_type", body_type_name(&ex.input.body));
224                            cx.span().set_attribute(KeyValue::new(
225                                "output_body_type",
226                                body_type_name(&ex.input.body),
227                            ));
228                        }
229                    }
230                    Err(e) => {
231                        let error_class = e.classify();
232                        cx.span().set_status(Status::error(e.to_string()));
233                        cx.span().add_event(
234                            "error",
235                            vec![
236                                KeyValue::new("error.type", error_class.to_string()),
237                                KeyValue::new("error.message", e.to_string()),
238                            ],
239                        );
240                        tracing::Span::current().record("status", "error");
241                        tracing::Span::current().record("error", e.to_string());
242                        tracing::Span::current().record("error_type", error_class);
243                    }
244                }
245
246                // Span is ended by _guard when it drops here
247                result
248            }
249            .instrument(tracing_span),
250        )
251    }
252}
253
254impl Clone for TracingProcessor {
255    fn clone(&self) -> Self {
256        Self {
257            inner: self.inner.clone(),
258            route_id: self.route_id.clone(),
259            step_id: self.step_id.clone(),
260            span_name: self.span_name.clone(),
261            step_index: self.step_index,
262            detail_level: self.detail_level.clone(),
263            metrics: self.metrics.clone(),
264        }
265    }
266}
267
268/// R4-L8: cap only the span-attr representation. Exchange.correlation_id is untouched.
269fn capped_correlation_id(id: &str) -> &str {
270    const CAP: usize = 128;
271    if id.len() > CAP {
272        "<oversized:correlation_id>"
273    } else {
274        id
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    //! Tests for TracingProcessor.
281    //!
282    //! These tests use the noop OTel provider, which means:
283    //! - Spans are created but not exported
284    //! - Span contexts may not have valid trace/span IDs
285    //! - Error recording on spans cannot be verified
286    //!
287    //! Full span hierarchy verification (trace ID matching, parent span ID, error recording)
288    //! requires an integration test with a real exporter, which will be covered in Task 11
289    //! (integration tests).
290
291    use super::*;
292    use camel_api::{BoxProcessorExt, IdentityProcessor, Message, Value};
293    use opentelemetry::trace::{SpanContext, SpanId, TraceFlags, TraceId, TraceState};
294    use std::time::Duration;
295    use tokio::sync::{OwnedSemaphorePermit, Semaphore};
296    use tower::ServiceExt;
297
298    #[tokio::test]
299    async fn test_tracing_processor_minimal() {
300        let inner = BoxProcessor::new(IdentityProcessor);
301        let mut tracer = TracingProcessor::new(
302            inner,
303            "test-route".to_string(),
304            0,
305            DetailLevel::Minimal,
306            None,
307        );
308
309        let exchange = Exchange::new(Message::default());
310        let result = tracer.ready().await.unwrap().call(exchange).await;
311
312        assert!(result.is_ok());
313    }
314
315    #[tokio::test]
316    async fn test_tracing_processor_medium_detail() {
317        let inner = BoxProcessor::new(IdentityProcessor);
318        let mut tracer = TracingProcessor::new(
319            inner,
320            "test-route".to_string(),
321            0,
322            DetailLevel::Medium,
323            None,
324        );
325
326        let exchange = Exchange::new(Message::default());
327        let result = tracer.ready().await.unwrap().call(exchange).await;
328
329        assert!(result.is_ok());
330    }
331
332    #[tokio::test]
333    async fn test_tracing_processor_full_detail() {
334        let inner = BoxProcessor::new(IdentityProcessor);
335        let mut tracer =
336            TracingProcessor::new(inner, "test-route".to_string(), 0, DetailLevel::Full, None);
337
338        let mut exchange = Exchange::new(Message::default());
339        exchange
340            .input
341            .headers
342            .insert("test".to_string(), Value::String("value".into()));
343
344        let result = tracer.ready().await.unwrap().call(exchange).await;
345
346        assert!(result.is_ok());
347    }
348
349    #[tokio::test]
350    async fn test_tracing_processor_clone() {
351        let inner = BoxProcessor::new(IdentityProcessor);
352        let tracer = TracingProcessor::new(
353            inner,
354            "test-route".to_string(),
355            1,
356            DetailLevel::Minimal,
357            None,
358        );
359
360        let mut cloned = tracer.clone();
361        let exchange = Exchange::new(Message::default());
362        let result = cloned.ready().await.unwrap().call(exchange).await;
363        assert!(result.is_ok());
364    }
365
366    #[tokio::test]
367    async fn test_tracing_processor_propagates_otel_context() {
368        let inner = BoxProcessor::new(IdentityProcessor);
369        let mut tracer = TracingProcessor::new(
370            inner,
371            "test-route".to_string(),
372            0,
373            DetailLevel::Minimal,
374            None,
375        );
376
377        // Start with an empty exchange (default context)
378        let exchange = Exchange::new(Message::default());
379        assert!(
380            !exchange.otel_context.span().span_context().is_valid(),
381            "Initial context should have invalid span"
382        );
383
384        let result = tracer.ready().await.unwrap().call(exchange).await;
385
386        // After processing, the exchange should have a new span context
387        let output_exchange = result.unwrap();
388
389        // The output exchange should now have a valid span context
390        // (even with noop provider, the span should be recorded)
391        // Note: With noop provider, span context may still be invalid
392        // but the context should be properly attached
393        let _span_context = output_exchange.otel_context.span().span_context();
394    }
395
396    #[tokio::test]
397    async fn test_tracing_processor_with_parent_context() {
398        let inner = BoxProcessor::new(IdentityProcessor);
399        let mut tracer = TracingProcessor::new(
400            inner,
401            "test-route".to_string(),
402            0,
403            DetailLevel::Minimal,
404            None,
405        );
406
407        // Create a parent span context
408        let trace_id = TraceId::from_hex("12345678901234567890123456789012").unwrap();
409        let span_id = SpanId::from_hex("1234567890123456").unwrap();
410        let parent_span_context = SpanContext::new(
411            trace_id,
412            span_id,
413            TraceFlags::SAMPLED,
414            true, // is_remote
415            TraceState::default(),
416        );
417
418        // Create exchange with parent context
419        let mut exchange = Exchange::new(Message::default());
420        exchange.otel_context = OtelContext::new().with_remote_span_context(parent_span_context);
421
422        // Store the initial parent span context for comparison
423        let initial_span_context = exchange.otel_context.span().span_context().clone();
424
425        // Verify parent context is set
426        assert!(
427            exchange.otel_context.span().span_context().is_valid(),
428            "Parent context should be valid"
429        );
430        let _parent_trace_id = exchange.otel_context.span().span_context().trace_id();
431
432        let result = tracer.ready().await.unwrap().call(exchange).await;
433
434        let output_exchange = result.unwrap();
435
436        // The output should still have a valid context
437        // The trace ID should be preserved from parent
438        let output_span = output_exchange.otel_context.span();
439        // With noop provider, we may not get a valid span context,
440        // but the context propagation mechanism should work
441        let _output_trace_id = output_span.span_context().trace_id();
442
443        // Verify that the exchange's otel_context has been updated (child span created)
444        // Even with noop provider, the span context should be a different object
445        // (the processor creates a new span, which may be a noop but is still a new span)
446        let output_span_context = output_span.span_context();
447        // The span contexts should be different objects (different span IDs conceptually,
448        // though noop provider may not actually assign them)
449        assert!(
450            !std::ptr::eq(&initial_span_context, output_span_context),
451            "exchange.otel_context should have been updated with a new child span context"
452        );
453    }
454
455    #[tokio::test]
456    async fn test_tracing_processor_records_error() {
457        // Create a processor that always fails
458        let failing_processor = BoxProcessor::from_fn(|_ex: Exchange| async move {
459            Err(CamelError::ProcessorError("intentional test error".into()))
460        });
461
462        let mut tracer = TracingProcessor::new(
463            failing_processor,
464            "test-route".to_string(),
465            0,
466            DetailLevel::Minimal,
467            None,
468        );
469
470        let exchange = Exchange::new(Message::default());
471        let result = tracer.ready().await.unwrap().call(exchange).await;
472
473        // Verify the error is correctly propagated
474        assert!(result.is_err());
475        let err = result.unwrap_err();
476        assert!(err.to_string().contains("intentional test error"));
477
478        // Note: With noop provider, we cannot verify that the error was recorded on the span.
479        // Full span hierarchy verification (trace ID matching, parent span ID, error recording)
480        // requires an integration test with a real exporter, which will be covered in Task 11
481        // (integration tests).
482    }
483
484    #[tokio::test]
485    async fn test_tracing_processor_span_name_format() {
486        let inner = BoxProcessor::new(IdentityProcessor);
487        let tracer =
488            TracingProcessor::new(inner, "my-route".to_string(), 5, DetailLevel::Minimal, None);
489
490        assert_eq!(tracer.span_name, "my-route:step-5");
491    }
492
493    #[tokio::test]
494    async fn test_tracing_processor_chained_propagation() {
495        // Test that multiple processors in a chain properly propagate context
496        let processor1 = BoxProcessor::new(IdentityProcessor);
497        let mut tracer1 = TracingProcessor::new(
498            processor1,
499            "route1".to_string(),
500            0,
501            DetailLevel::Minimal,
502            None,
503        );
504
505        let processor2 = BoxProcessor::new(IdentityProcessor);
506        let mut tracer2 = TracingProcessor::new(
507            processor2,
508            "route2".to_string(),
509            1,
510            DetailLevel::Minimal,
511            None,
512        );
513
514        let exchange = Exchange::new(Message::default());
515        let result1 = tracer1.ready().await.unwrap().call(exchange).await;
516        let exchange1 = result1.unwrap();
517
518        // Pass the exchange through second processor
519        let result2 = tracer2.ready().await.unwrap().call(exchange1).await;
520        let exchange2 = result2.unwrap();
521
522        // Both processors should have updated the context
523        // The context should be valid and propagating
524        let _ = exchange2.otel_context;
525    }
526
527    #[test]
528    fn capped_correlation_id_uses_sentinel_for_oversized() {
529        assert_eq!(
530            capped_correlation_id(&"x".repeat(200)),
531            "<oversized:correlation_id>"
532        );
533        assert_eq!(
534            capped_correlation_id(&"y".repeat(300)),
535            "<oversized:correlation_id>"
536        );
537        assert_eq!(capped_correlation_id("abc-123"), "abc-123");
538    }
539
540    /// Mock inner mirroring `DirectProducer`'s stateful readiness: `poll_ready`
541    /// acquires the sole permit of a shared semaphore into `pending_permit`,
542    /// and `Clone` shares the semaphore but drops the permit. Calling `call`
543    /// without a reserved permit fails, so any readiness state loss is
544    /// detected instead of silently proceeding.
545    struct PermitGateInner {
546        semaphore: Arc<Semaphore>,
547        pending_permit: Option<OwnedSemaphorePermit>,
548    }
549
550    impl PermitGateInner {
551        fn new() -> Self {
552            Self {
553                semaphore: Arc::new(Semaphore::new(1)),
554                pending_permit: None,
555            }
556        }
557    }
558
559    impl Clone for PermitGateInner {
560        fn clone(&self) -> Self {
561            Self {
562                semaphore: Arc::clone(&self.semaphore),
563                pending_permit: None,
564            }
565        }
566    }
567
568    impl Service<Exchange> for PermitGateInner {
569        type Response = Exchange;
570        type Error = CamelError;
571        type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
572
573        fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
574            // Already holding the permit: ready.
575            if self.pending_permit.is_some() {
576                return Poll::Ready(Ok(()));
577            }
578            let mut fut = std::pin::pin!(Arc::clone(&self.semaphore).acquire_owned());
579            match fut.as_mut().poll(cx) {
580                Poll::Ready(Ok(permit)) => {
581                    self.pending_permit = Some(permit);
582                    Poll::Ready(Ok(()))
583                }
584                Poll::Pending => Poll::Pending,
585                // Unreachable in practice: the semaphore is never closed.
586                Poll::Ready(Err(err)) => {
587                    Poll::Ready(Err(CamelError::ProcessorError(err.to_string())))
588                }
589            }
590        }
591
592        fn call(&mut self, exchange: Exchange) -> Self::Future {
593            let permit = self.pending_permit.take();
594            Box::pin(async move {
595                match permit {
596                    Some(_permit) => Ok(exchange),
597                    None => Err(CamelError::ProcessorError(
598                        "call() invoked without a reserved permit".into(),
599                    )),
600                }
601            })
602        }
603    }
604
605    #[tokio::test]
606    async fn tracing_processor_does_not_re_ready_clone() {
607        let mock_inner = BoxProcessor::new(PermitGateInner::new());
608        let mut tracing_proc =
609            TracingProcessor::new(mock_inner, "r".to_string(), 0, DetailLevel::Minimal, None);
610
611        let exchange = Exchange::new(Message::default());
612        let outcome = tokio::time::timeout(Duration::from_secs(5), async {
613            tracing_proc.ready().await.unwrap().call(exchange).await
614        })
615        .await
616        .expect("deadlock: TracingProcessor re-readied a clone whose permit was dropped");
617
618        assert!(outcome.is_ok());
619    }
620
621    #[tokio::test]
622    async fn tracing_processor_reusable_across_sequential_cycles() {
623        let mock_inner = BoxProcessor::new(PermitGateInner::new());
624        let mut tracing_proc =
625            TracingProcessor::new(mock_inner, "r".to_string(), 0, DetailLevel::Minimal, None);
626
627        let ex_a = Exchange::new(Message::default());
628        let outcome_a = tokio::time::timeout(Duration::from_secs(5), async {
629            tracing_proc.ready().await.unwrap().call(ex_a).await
630        })
631        .await
632        .expect("first cycle timed out");
633        assert!(outcome_a.is_ok());
634
635        let ex_b = Exchange::new(Message::default());
636        let outcome_b = tokio::time::timeout(Duration::from_secs(5), async {
637            tracing_proc.ready().await.unwrap().call(ex_b).await
638        })
639        .await
640        .expect("second cycle timed out");
641        assert!(outcome_b.is_ok());
642    }
643}