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        _ => "unknown",
38    }
39}
40
41/// A processor wrapper that emits tracing spans for each step.
42///
43/// This processor wraps another processor and adds distributed tracing by:
44/// 1. Starting a native OpenTelemetry span for each exchange
45/// 2. Propagating the OTel context through `exchange.otel_context`
46/// 3. Recording errors and status on the span
47///
48/// When no OTel provider is configured (noop provider), spans are no-ops with minimal overhead.
49pub struct TracingProcessor {
50    inner: BoxProcessor,
51    route_id: String,
52    step_id: String,
53    span_name: String,
54    step_index: usize,
55    detail_level: DetailLevel,
56    metrics: Option<Arc<dyn MetricsCollector>>,
57}
58
59impl TracingProcessor {
60    /// Wrap a processor with tracing.
61    pub fn new(
62        inner: BoxProcessor,
63        route_id: String,
64        step_index: usize,
65        detail_level: DetailLevel,
66        metrics: Option<Arc<dyn MetricsCollector>>,
67    ) -> Self {
68        let step_id = format!("step-{}", step_index);
69        let span_name = format!("{route_id}:{step_id}");
70        Self {
71            inner,
72            route_id,
73            step_id,
74            span_name,
75            step_index,
76            detail_level,
77            metrics,
78        }
79    }
80}
81
82impl Service<Exchange> for TracingProcessor {
83    type Response = Exchange;
84    type Error = CamelError;
85    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
86
87    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
88        self.inner.poll_ready(cx)
89    }
90
91    fn call(&mut self, mut exchange: Exchange) -> Self::Future {
92        let start = Instant::now();
93        let span_name = self.span_name.clone();
94
95        // Get the global tracer (noop if no provider is configured)
96        let tracer = global::tracer("camel-core");
97
98        // Extract parent context from exchange.otel_context
99        let parent_cx = exchange.otel_context.clone();
100
101        // Build span attributes
102        let mut attributes = [
103            KeyValue::new("messaging.system", "camel"),
104            KeyValue::new(
105                "correlation_id",
106                capped_correlation_id(exchange.correlation_id()).to_string(),
107            ),
108            KeyValue::new("route_id", self.route_id.clone()),
109            KeyValue::new("step_id", self.step_id.clone()),
110            KeyValue::new("step_index", self.step_index as i64),
111            KeyValue::new("headers_count", 0i64),
112            KeyValue::new("body_type", ""),
113            KeyValue::new("has_error", false),
114        ];
115        let mut attr_count = 5;
116
117        if self.detail_level >= DetailLevel::Medium {
118            attributes[5] = KeyValue::new("headers_count", exchange.input.headers.len() as i64);
119            attributes[6] = KeyValue::new("body_type", body_type_name(&exchange.input.body));
120            attributes[7] = KeyValue::new("has_error", exchange.has_error());
121            attr_count = 8;
122        }
123
124        // Start a new span as a child of the parent context
125        let span = tracer
126            .span_builder(span_name)
127            .with_kind(SpanKind::Internal)
128            .with_attributes(attributes[..attr_count].iter().cloned())
129            .start_with_context(&tracer, &parent_cx);
130
131        // Create new context with this span as the active span
132        let cx = OtelContext::current_with_span(span);
133
134        // Store back into exchange so downstream processors inherit this context
135        exchange.otel_context = cx.clone();
136
137        // Also create a tracing span for local dev logging
138        let tracing_span = tracing::info_span!(
139            target: "camel_tracer",
140            "step",
141            correlation_id = %exchange.correlation_id(),
142            route_id = %self.route_id,
143            step_id = %self.step_id,
144            step_index = self.step_index,
145            duration_ms = tracing::field::Empty,
146            status = tracing::field::Empty,
147            headers_count = tracing::field::Empty,
148            body_type = tracing::field::Empty,
149            has_error = tracing::field::Empty,
150            output_body_type = tracing::field::Empty,
151            header_0 = tracing::field::Empty,
152            header_1 = tracing::field::Empty,
153            header_2 = tracing::field::Empty,
154            error = tracing::field::Empty,
155            error_type = tracing::field::Empty,
156        );
157
158        if self.detail_level >= DetailLevel::Medium {
159            tracing_span.record("headers_count", exchange.input.headers.len() as u64);
160            tracing_span.record("body_type", body_type_name(&exchange.input.body));
161            tracing_span.record("has_error", exchange.has_error());
162        }
163
164        if self.detail_level >= DetailLevel::Full {
165            let headers: Vec<_> = exchange.input.headers.iter().take(3).collect();
166            if let Some((k, v)) = headers.first() {
167                tracing_span.record("header_0", format!("{k}={v:?}"));
168            }
169            if let Some((k, v)) = headers.get(1) {
170                tracing_span.record("header_1", format!("{k}={v:?}"));
171            }
172            if let Some((k, v)) = headers.get(2) {
173                tracing_span.record("header_2", format!("{k}={v:?}"));
174            }
175        }
176
177        let mut inner = self.inner.clone();
178        let detail_level = self.detail_level.clone();
179        let metrics = self.metrics.clone();
180        let route_id = self.route_id.clone();
181
182        Box::pin(
183            async move {
184                // Note: ContextGuard is not Send (it uses thread-local storage), so we cannot
185                // hold it across await points in an async fn. Instead, we propagate the OTel
186                // context through exchange.otel_context, which is Send + Sync.
187
188                // Create guard to ensure span is ended even on panic
189                let _guard = SpanEndGuard(cx.clone());
190
191                let result = inner.ready().await?.call(exchange).await;
192
193                let duration = start.elapsed();
194                let duration_ms = duration.as_millis() as u64;
195                tracing::Span::current().record("duration_ms", duration_ms);
196
197                // Record duration on OTel span
198                cx.span()
199                    .set_attribute(KeyValue::new("duration_ms", duration_ms as i64));
200
201                // Record metrics if collector is present
202                if let Some(ref metrics) = metrics {
203                    metrics.record_exchange_duration(&route_id, duration);
204                    metrics.increment_exchanges(&route_id);
205
206                    if let Err(e) = &result {
207                        metrics.increment_errors(&route_id, e.classify());
208                    }
209                }
210
211                match &result {
212                    Ok(ex) => {
213                        tracing::Span::current().record("status", "success");
214                        cx.span().set_status(Status::Ok);
215
216                        if detail_level >= DetailLevel::Medium {
217                            tracing::Span::current()
218                                .record("output_body_type", body_type_name(&ex.input.body));
219                            cx.span().set_attribute(KeyValue::new(
220                                "output_body_type",
221                                body_type_name(&ex.input.body),
222                            ));
223                        }
224                    }
225                    Err(e) => {
226                        let error_class = e.classify();
227                        cx.span().set_status(Status::error(e.to_string()));
228                        cx.span().add_event(
229                            "error",
230                            vec![
231                                KeyValue::new("error.type", error_class.to_string()),
232                                KeyValue::new("error.message", e.to_string()),
233                            ],
234                        );
235                        tracing::Span::current().record("status", "error");
236                        tracing::Span::current().record("error", e.to_string());
237                        tracing::Span::current().record("error_type", error_class);
238                    }
239                }
240
241                // Span is ended by _guard when it drops here
242                result
243            }
244            .instrument(tracing_span),
245        )
246    }
247}
248
249impl Clone for TracingProcessor {
250    fn clone(&self) -> Self {
251        Self {
252            inner: self.inner.clone(),
253            route_id: self.route_id.clone(),
254            step_id: self.step_id.clone(),
255            span_name: self.span_name.clone(),
256            step_index: self.step_index,
257            detail_level: self.detail_level.clone(),
258            metrics: self.metrics.clone(),
259        }
260    }
261}
262
263/// R4-L8: cap only the span-attr representation. Exchange.correlation_id is untouched.
264fn capped_correlation_id(id: &str) -> &str {
265    const CAP: usize = 128;
266    if id.len() > CAP {
267        "<oversized:correlation_id>"
268    } else {
269        id
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    //! Tests for TracingProcessor.
276    //!
277    //! These tests use the noop OTel provider, which means:
278    //! - Spans are created but not exported
279    //! - Span contexts may not have valid trace/span IDs
280    //! - Error recording on spans cannot be verified
281    //!
282    //! Full span hierarchy verification (trace ID matching, parent span ID, error recording)
283    //! requires an integration test with a real exporter, which will be covered in Task 11
284    //! (integration tests).
285
286    use super::*;
287    use camel_api::{BoxProcessorExt, IdentityProcessor, Message, Value};
288    use opentelemetry::trace::{SpanContext, SpanId, TraceFlags, TraceId, TraceState};
289    use tower::ServiceExt;
290
291    #[tokio::test]
292    async fn test_tracing_processor_minimal() {
293        let inner = BoxProcessor::new(IdentityProcessor);
294        let mut tracer = TracingProcessor::new(
295            inner,
296            "test-route".to_string(),
297            0,
298            DetailLevel::Minimal,
299            None,
300        );
301
302        let exchange = Exchange::new(Message::default());
303        let result = tracer.ready().await.unwrap().call(exchange).await;
304
305        assert!(result.is_ok());
306    }
307
308    #[tokio::test]
309    async fn test_tracing_processor_medium_detail() {
310        let inner = BoxProcessor::new(IdentityProcessor);
311        let mut tracer = TracingProcessor::new(
312            inner,
313            "test-route".to_string(),
314            0,
315            DetailLevel::Medium,
316            None,
317        );
318
319        let exchange = Exchange::new(Message::default());
320        let result = tracer.ready().await.unwrap().call(exchange).await;
321
322        assert!(result.is_ok());
323    }
324
325    #[tokio::test]
326    async fn test_tracing_processor_full_detail() {
327        let inner = BoxProcessor::new(IdentityProcessor);
328        let mut tracer =
329            TracingProcessor::new(inner, "test-route".to_string(), 0, DetailLevel::Full, None);
330
331        let mut exchange = Exchange::new(Message::default());
332        exchange
333            .input
334            .headers
335            .insert("test".to_string(), Value::String("value".into()));
336
337        let result = tracer.ready().await.unwrap().call(exchange).await;
338
339        assert!(result.is_ok());
340    }
341
342    #[tokio::test]
343    async fn test_tracing_processor_clone() {
344        let inner = BoxProcessor::new(IdentityProcessor);
345        let tracer = TracingProcessor::new(
346            inner,
347            "test-route".to_string(),
348            1,
349            DetailLevel::Minimal,
350            None,
351        );
352
353        let mut cloned = tracer.clone();
354        let exchange = Exchange::new(Message::default());
355        let result = cloned.ready().await.unwrap().call(exchange).await;
356        assert!(result.is_ok());
357    }
358
359    #[tokio::test]
360    async fn test_tracing_processor_propagates_otel_context() {
361        let inner = BoxProcessor::new(IdentityProcessor);
362        let mut tracer = TracingProcessor::new(
363            inner,
364            "test-route".to_string(),
365            0,
366            DetailLevel::Minimal,
367            None,
368        );
369
370        // Start with an empty exchange (default context)
371        let exchange = Exchange::new(Message::default());
372        assert!(
373            !exchange.otel_context.span().span_context().is_valid(),
374            "Initial context should have invalid span"
375        );
376
377        let result = tracer.ready().await.unwrap().call(exchange).await;
378
379        // After processing, the exchange should have a new span context
380        let output_exchange = result.unwrap();
381
382        // The output exchange should now have a valid span context
383        // (even with noop provider, the span should be recorded)
384        // Note: With noop provider, span context may still be invalid
385        // but the context should be properly attached
386        let _span_context = output_exchange.otel_context.span().span_context();
387    }
388
389    #[tokio::test]
390    async fn test_tracing_processor_with_parent_context() {
391        let inner = BoxProcessor::new(IdentityProcessor);
392        let mut tracer = TracingProcessor::new(
393            inner,
394            "test-route".to_string(),
395            0,
396            DetailLevel::Minimal,
397            None,
398        );
399
400        // Create a parent span context
401        let trace_id = TraceId::from_hex("12345678901234567890123456789012").unwrap();
402        let span_id = SpanId::from_hex("1234567890123456").unwrap();
403        let parent_span_context = SpanContext::new(
404            trace_id,
405            span_id,
406            TraceFlags::SAMPLED,
407            true, // is_remote
408            TraceState::default(),
409        );
410
411        // Create exchange with parent context
412        let mut exchange = Exchange::new(Message::default());
413        exchange.otel_context = OtelContext::new().with_remote_span_context(parent_span_context);
414
415        // Store the initial parent span context for comparison
416        let initial_span_context = exchange.otel_context.span().span_context().clone();
417
418        // Verify parent context is set
419        assert!(
420            exchange.otel_context.span().span_context().is_valid(),
421            "Parent context should be valid"
422        );
423        let _parent_trace_id = exchange.otel_context.span().span_context().trace_id();
424
425        let result = tracer.ready().await.unwrap().call(exchange).await;
426
427        let output_exchange = result.unwrap();
428
429        // The output should still have a valid context
430        // The trace ID should be preserved from parent
431        let output_span = output_exchange.otel_context.span();
432        // With noop provider, we may not get a valid span context,
433        // but the context propagation mechanism should work
434        let _output_trace_id = output_span.span_context().trace_id();
435
436        // Verify that the exchange's otel_context has been updated (child span created)
437        // Even with noop provider, the span context should be a different object
438        // (the processor creates a new span, which may be a noop but is still a new span)
439        let output_span_context = output_span.span_context();
440        // The span contexts should be different objects (different span IDs conceptually,
441        // though noop provider may not actually assign them)
442        assert!(
443            !std::ptr::eq(&initial_span_context, output_span_context),
444            "exchange.otel_context should have been updated with a new child span context"
445        );
446    }
447
448    #[tokio::test]
449    async fn test_tracing_processor_records_error() {
450        // Create a processor that always fails
451        let failing_processor = BoxProcessor::from_fn(|_ex: Exchange| async move {
452            Err(CamelError::ProcessorError("intentional test error".into()))
453        });
454
455        let mut tracer = TracingProcessor::new(
456            failing_processor,
457            "test-route".to_string(),
458            0,
459            DetailLevel::Minimal,
460            None,
461        );
462
463        let exchange = Exchange::new(Message::default());
464        let result = tracer.ready().await.unwrap().call(exchange).await;
465
466        // Verify the error is correctly propagated
467        assert!(result.is_err());
468        let err = result.unwrap_err();
469        assert!(err.to_string().contains("intentional test error"));
470
471        // Note: With noop provider, we cannot verify that the error was recorded on the span.
472        // Full span hierarchy verification (trace ID matching, parent span ID, error recording)
473        // requires an integration test with a real exporter, which will be covered in Task 11
474        // (integration tests).
475    }
476
477    #[tokio::test]
478    async fn test_tracing_processor_span_name_format() {
479        let inner = BoxProcessor::new(IdentityProcessor);
480        let tracer =
481            TracingProcessor::new(inner, "my-route".to_string(), 5, DetailLevel::Minimal, None);
482
483        assert_eq!(tracer.span_name, "my-route:step-5");
484    }
485
486    #[tokio::test]
487    async fn test_tracing_processor_chained_propagation() {
488        // Test that multiple processors in a chain properly propagate context
489        let processor1 = BoxProcessor::new(IdentityProcessor);
490        let mut tracer1 = TracingProcessor::new(
491            processor1,
492            "route1".to_string(),
493            0,
494            DetailLevel::Minimal,
495            None,
496        );
497
498        let processor2 = BoxProcessor::new(IdentityProcessor);
499        let mut tracer2 = TracingProcessor::new(
500            processor2,
501            "route2".to_string(),
502            1,
503            DetailLevel::Minimal,
504            None,
505        );
506
507        let exchange = Exchange::new(Message::default());
508        let result1 = tracer1.ready().await.unwrap().call(exchange).await;
509        let exchange1 = result1.unwrap();
510
511        // Pass the exchange through second processor
512        let result2 = tracer2.ready().await.unwrap().call(exchange1).await;
513        let exchange2 = result2.unwrap();
514
515        // Both processors should have updated the context
516        // The context should be valid and propagating
517        let _ = exchange2.otel_context;
518    }
519
520    #[test]
521    fn capped_correlation_id_uses_sentinel_for_oversized() {
522        assert_eq!(
523            capped_correlation_id(&"x".repeat(200)),
524            "<oversized:correlation_id>"
525        );
526        assert_eq!(
527            capped_correlation_id(&"y".repeat(300)),
528            "<oversized:correlation_id>"
529        );
530        assert_eq!(capped_correlation_id("abc-123"), "abc-123");
531    }
532}