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 tower::ServiceExt;
11use tracing::Instrument;
12
13use crate::shared::observability::domain::DetailLevel;
14use camel_api::metrics::MetricsCollector;
15use camel_api::{Body, BoxProcessor, CamelError, Exchange};
16
17/// RAII guard that ensures an OTel span is ended when dropped.
18///
19/// This prevents span leaks if the inner processor panics or returns early.
20struct SpanEndGuard(OtelContext);
21
22impl Drop for SpanEndGuard {
23    fn drop(&mut self) {
24        self.0.span().end();
25    }
26}
27
28/// Returns a human-readable name for the body type variant.
29fn body_type_name(body: &Body) -> &'static str {
30    match body {
31        Body::Empty => "empty",
32        Body::Bytes(_) => "bytes",
33        Body::Text(_) => "text",
34        Body::Json(_) => "json",
35        Body::Xml(_) => "xml",
36        Body::Stream(_) => "stream",
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        let mut inner = self.inner.clone();
177        let detail_level = self.detail_level.clone();
178        let metrics = self.metrics.clone();
179        let route_id = self.route_id.clone();
180
181        Box::pin(
182            async move {
183                // Note: ContextGuard is not Send (it uses thread-local storage), so we cannot
184                // hold it across await points in an async fn. Instead, we propagate the OTel
185                // context through exchange.otel_context, which is Send + Sync.
186
187                // Create guard to ensure span is ended even on panic
188                let _guard = SpanEndGuard(cx.clone());
189
190                let result = inner.ready().await?.call(exchange).await;
191
192                let duration = start.elapsed();
193                let duration_ms = duration.as_millis() as u64;
194                tracing::Span::current().record("duration_ms", duration_ms);
195
196                // Record duration on OTel span
197                cx.span()
198                    .set_attribute(KeyValue::new("duration_ms", duration_ms as i64));
199
200                // Record metrics if collector is present
201                if let Some(ref metrics) = metrics {
202                    metrics.record_exchange_duration(&route_id, duration);
203                    metrics.increment_exchanges(&route_id);
204
205                    if let Err(e) = &result {
206                        metrics.increment_errors(&route_id, e.classify());
207                    }
208                }
209
210                match &result {
211                    Ok(ex) => {
212                        tracing::Span::current().record("status", "success");
213                        cx.span().set_status(Status::Ok);
214
215                        if detail_level >= DetailLevel::Medium {
216                            tracing::Span::current()
217                                .record("output_body_type", body_type_name(&ex.input.body));
218                            cx.span().set_attribute(KeyValue::new(
219                                "output_body_type",
220                                body_type_name(&ex.input.body),
221                            ));
222                        }
223                    }
224                    Err(e) => {
225                        let error_class = e.classify();
226                        cx.span().set_status(Status::error(e.to_string()));
227                        cx.span().add_event(
228                            "error",
229                            vec![
230                                KeyValue::new("error.type", error_class.to_string()),
231                                KeyValue::new("error.message", e.to_string()),
232                            ],
233                        );
234                        tracing::Span::current().record("status", "error");
235                        tracing::Span::current().record("error", e.to_string());
236                        tracing::Span::current().record("error_type", error_class);
237                    }
238                }
239
240                // Span is ended by _guard when it drops here
241                result
242            }
243            .instrument(tracing_span),
244        )
245    }
246}
247
248impl Clone for TracingProcessor {
249    fn clone(&self) -> Self {
250        Self {
251            inner: self.inner.clone(),
252            route_id: self.route_id.clone(),
253            step_id: self.step_id.clone(),
254            span_name: self.span_name.clone(),
255            step_index: self.step_index,
256            detail_level: self.detail_level.clone(),
257            metrics: self.metrics.clone(),
258        }
259    }
260}
261
262/// R4-L8: cap only the span-attr representation. Exchange.correlation_id is untouched.
263fn capped_correlation_id(id: &str) -> &str {
264    const CAP: usize = 128;
265    if id.len() > CAP {
266        "<oversized:correlation_id>"
267    } else {
268        id
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    //! Tests for TracingProcessor.
275    //!
276    //! These tests use the noop OTel provider, which means:
277    //! - Spans are created but not exported
278    //! - Span contexts may not have valid trace/span IDs
279    //! - Error recording on spans cannot be verified
280    //!
281    //! Full span hierarchy verification (trace ID matching, parent span ID, error recording)
282    //! requires an integration test with a real exporter, which will be covered in Task 11
283    //! (integration tests).
284
285    use super::*;
286    use camel_api::{BoxProcessorExt, IdentityProcessor, Message, Value};
287    use opentelemetry::trace::{SpanContext, SpanId, TraceFlags, TraceId, TraceState};
288    use tower::ServiceExt;
289
290    #[tokio::test]
291    async fn test_tracing_processor_minimal() {
292        let inner = BoxProcessor::new(IdentityProcessor);
293        let mut tracer = TracingProcessor::new(
294            inner,
295            "test-route".to_string(),
296            0,
297            DetailLevel::Minimal,
298            None,
299        );
300
301        let exchange = Exchange::new(Message::default());
302        let result = tracer.ready().await.unwrap().call(exchange).await;
303
304        assert!(result.is_ok());
305    }
306
307    #[tokio::test]
308    async fn test_tracing_processor_medium_detail() {
309        let inner = BoxProcessor::new(IdentityProcessor);
310        let mut tracer = TracingProcessor::new(
311            inner,
312            "test-route".to_string(),
313            0,
314            DetailLevel::Medium,
315            None,
316        );
317
318        let exchange = Exchange::new(Message::default());
319        let result = tracer.ready().await.unwrap().call(exchange).await;
320
321        assert!(result.is_ok());
322    }
323
324    #[tokio::test]
325    async fn test_tracing_processor_full_detail() {
326        let inner = BoxProcessor::new(IdentityProcessor);
327        let mut tracer =
328            TracingProcessor::new(inner, "test-route".to_string(), 0, DetailLevel::Full, None);
329
330        let mut exchange = Exchange::new(Message::default());
331        exchange
332            .input
333            .headers
334            .insert("test".to_string(), Value::String("value".into()));
335
336        let result = tracer.ready().await.unwrap().call(exchange).await;
337
338        assert!(result.is_ok());
339    }
340
341    #[tokio::test]
342    async fn test_tracing_processor_clone() {
343        let inner = BoxProcessor::new(IdentityProcessor);
344        let tracer = TracingProcessor::new(
345            inner,
346            "test-route".to_string(),
347            1,
348            DetailLevel::Minimal,
349            None,
350        );
351
352        let mut cloned = tracer.clone();
353        let exchange = Exchange::new(Message::default());
354        let result = cloned.ready().await.unwrap().call(exchange).await;
355        assert!(result.is_ok());
356    }
357
358    #[tokio::test]
359    async fn test_tracing_processor_propagates_otel_context() {
360        let inner = BoxProcessor::new(IdentityProcessor);
361        let mut tracer = TracingProcessor::new(
362            inner,
363            "test-route".to_string(),
364            0,
365            DetailLevel::Minimal,
366            None,
367        );
368
369        // Start with an empty exchange (default context)
370        let exchange = Exchange::new(Message::default());
371        assert!(
372            !exchange.otel_context.span().span_context().is_valid(),
373            "Initial context should have invalid span"
374        );
375
376        let result = tracer.ready().await.unwrap().call(exchange).await;
377
378        // After processing, the exchange should have a new span context
379        let output_exchange = result.unwrap();
380
381        // The output exchange should now have a valid span context
382        // (even with noop provider, the span should be recorded)
383        // Note: With noop provider, span context may still be invalid
384        // but the context should be properly attached
385        let _span_context = output_exchange.otel_context.span().span_context();
386    }
387
388    #[tokio::test]
389    async fn test_tracing_processor_with_parent_context() {
390        let inner = BoxProcessor::new(IdentityProcessor);
391        let mut tracer = TracingProcessor::new(
392            inner,
393            "test-route".to_string(),
394            0,
395            DetailLevel::Minimal,
396            None,
397        );
398
399        // Create a parent span context
400        let trace_id = TraceId::from_hex("12345678901234567890123456789012").unwrap();
401        let span_id = SpanId::from_hex("1234567890123456").unwrap();
402        let parent_span_context = SpanContext::new(
403            trace_id,
404            span_id,
405            TraceFlags::SAMPLED,
406            true, // is_remote
407            TraceState::default(),
408        );
409
410        // Create exchange with parent context
411        let mut exchange = Exchange::new(Message::default());
412        exchange.otel_context = OtelContext::new().with_remote_span_context(parent_span_context);
413
414        // Store the initial parent span context for comparison
415        let initial_span_context = exchange.otel_context.span().span_context().clone();
416
417        // Verify parent context is set
418        assert!(
419            exchange.otel_context.span().span_context().is_valid(),
420            "Parent context should be valid"
421        );
422        let _parent_trace_id = exchange.otel_context.span().span_context().trace_id();
423
424        let result = tracer.ready().await.unwrap().call(exchange).await;
425
426        let output_exchange = result.unwrap();
427
428        // The output should still have a valid context
429        // The trace ID should be preserved from parent
430        let output_span = output_exchange.otel_context.span();
431        // With noop provider, we may not get a valid span context,
432        // but the context propagation mechanism should work
433        let _output_trace_id = output_span.span_context().trace_id();
434
435        // Verify that the exchange's otel_context has been updated (child span created)
436        // Even with noop provider, the span context should be a different object
437        // (the processor creates a new span, which may be a noop but is still a new span)
438        let output_span_context = output_span.span_context();
439        // The span contexts should be different objects (different span IDs conceptually,
440        // though noop provider may not actually assign them)
441        assert!(
442            !std::ptr::eq(&initial_span_context, output_span_context),
443            "exchange.otel_context should have been updated with a new child span context"
444        );
445    }
446
447    #[tokio::test]
448    async fn test_tracing_processor_records_error() {
449        // Create a processor that always fails
450        let failing_processor = BoxProcessor::from_fn(|_ex: Exchange| async move {
451            Err(CamelError::ProcessorError("intentional test error".into()))
452        });
453
454        let mut tracer = TracingProcessor::new(
455            failing_processor,
456            "test-route".to_string(),
457            0,
458            DetailLevel::Minimal,
459            None,
460        );
461
462        let exchange = Exchange::new(Message::default());
463        let result = tracer.ready().await.unwrap().call(exchange).await;
464
465        // Verify the error is correctly propagated
466        assert!(result.is_err());
467        let err = result.unwrap_err();
468        assert!(err.to_string().contains("intentional test error"));
469
470        // Note: With noop provider, we cannot verify that the error was recorded on the span.
471        // Full span hierarchy verification (trace ID matching, parent span ID, error recording)
472        // requires an integration test with a real exporter, which will be covered in Task 11
473        // (integration tests).
474    }
475
476    #[tokio::test]
477    async fn test_tracing_processor_span_name_format() {
478        let inner = BoxProcessor::new(IdentityProcessor);
479        let tracer =
480            TracingProcessor::new(inner, "my-route".to_string(), 5, DetailLevel::Minimal, None);
481
482        assert_eq!(tracer.span_name, "my-route:step-5");
483    }
484
485    #[tokio::test]
486    async fn test_tracing_processor_chained_propagation() {
487        // Test that multiple processors in a chain properly propagate context
488        let processor1 = BoxProcessor::new(IdentityProcessor);
489        let mut tracer1 = TracingProcessor::new(
490            processor1,
491            "route1".to_string(),
492            0,
493            DetailLevel::Minimal,
494            None,
495        );
496
497        let processor2 = BoxProcessor::new(IdentityProcessor);
498        let mut tracer2 = TracingProcessor::new(
499            processor2,
500            "route2".to_string(),
501            1,
502            DetailLevel::Minimal,
503            None,
504        );
505
506        let exchange = Exchange::new(Message::default());
507        let result1 = tracer1.ready().await.unwrap().call(exchange).await;
508        let exchange1 = result1.unwrap();
509
510        // Pass the exchange through second processor
511        let result2 = tracer2.ready().await.unwrap().call(exchange1).await;
512        let exchange2 = result2.unwrap();
513
514        // Both processors should have updated the context
515        // The context should be valid and propagating
516        let _ = exchange2.otel_context;
517    }
518
519    #[test]
520    fn capped_correlation_id_uses_sentinel_for_oversized() {
521        assert_eq!(
522            capped_correlation_id(&"x".repeat(200)),
523            "<oversized:correlation_id>"
524        );
525        assert_eq!(
526            capped_correlation_id(&"y".repeat(300)),
527            "<oversized:correlation_id>"
528        );
529        assert_eq!(capped_correlation_id("abc-123"), "abc-123");
530    }
531}