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, SpanRef, Status, TraceContextExt, Tracer};
8use opentelemetry::{Context as OtelContext, InstrumentationScope, KeyValue, global};
9use tower::Service;
10use tracing::Instrument;
11
12use crate::shared::observability::domain::{DetailLevel, MetricsLeversConfig};
13use camel_api::metrics::MetricsCollector;
14use camel_api::{BoxProcessor, CIRCUIT_OPEN, CamelError, Exchange, SpanKindHint, body_type_name};
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.
19/// `pub(crate)` so the route compiler can reuse it for the route root span.
20pub(crate) struct SpanEndGuard(pub(crate) OtelContext);
21
22impl Drop for SpanEndGuard {
23    fn drop(&mut self) {
24        self.0.span().end();
25    }
26}
27
28/// A processor wrapper that emits tracing spans for each step.
29///
30/// This processor wraps another processor and adds distributed tracing by:
31/// 1. Starting a native OpenTelemetry span for each exchange
32/// 2. Propagating the OTel context through `exchange.otel_context`
33/// 3. Recording errors and status on the span
34///
35/// When no OTel provider is configured (noop provider), spans are no-ops with minimal overhead.
36pub struct TracingProcessor {
37    inner: BoxProcessor,
38    route_id: String,
39    step_id: String,
40    span_name: String,
41    step_index: usize,
42    detail_level: DetailLevel,
43    metrics: Option<Arc<dyn MetricsCollector>>,
44    /// OTel span kind precomputed from `SpanKindHint` at construction.
45    span_kind: SpanKind,
46    /// Whether step spans are created. Gates SPANS only — metric families
47    /// (errors unconditionally) keep flowing when false
48    /// (metrics-configuration Req 1).
49    spans_enabled: bool,
50    /// Per-family metric levers; `increment_errors` is never gated.
51    metric_levers: MetricsLeversConfig,
52}
53
54/// Positional fallback span-name fragment for unlabeled steps. Shared by
55/// `TracingProcessor` (process steps) and `segment_span` (segment steps).
56pub(crate) fn step_id_for(index: usize) -> String {
57    format!("step-{index}")
58}
59
60impl TracingProcessor {
61    /// Wrap a processor with tracing.
62    ///
63    /// `label` names the span after the DSL step it wraps (e.g. `log`,
64    /// `to:direct`); when `None` the span falls back to the positional
65    /// `step-{index}` id. `kind_hint` selects the OTel span kind for the
66    /// step span and is converted once here.
67    pub fn new(
68        inner: BoxProcessor,
69        route_id: String,
70        step_index: usize,
71        detail_level: DetailLevel,
72        metrics: Option<Arc<dyn MetricsCollector>>,
73        label: Option<Arc<str>>,
74        kind_hint: SpanKindHint,
75    ) -> Self {
76        let step_id = step_id_for(step_index);
77        let span_name = format!("{route_id}:{}", label.as_deref().unwrap_or(&step_id));
78        let span_kind = match kind_hint {
79            SpanKindHint::Internal => SpanKind::Internal,
80            SpanKindHint::Producer => SpanKind::Producer,
81            SpanKindHint::Consumer => SpanKind::Consumer,
82            SpanKindHint::Client => SpanKind::Client,
83            SpanKindHint::Server => SpanKind::Server,
84            // `SpanKindHint` is `#[non_exhaustive]`: unknown future variants
85            // degrade to `Internal` (the promised forward-compat behavior).
86            _ => SpanKind::Internal,
87        };
88        Self {
89            inner,
90            route_id,
91            step_id,
92            span_name,
93            step_index,
94            detail_level,
95            metrics,
96            span_kind,
97            // Defaults preserve the fully-traced behavior for direct
98            // constructors; the pipeline composer overrides per the
99            // effective tracer config.
100            spans_enabled: true,
101            metric_levers: MetricsLeversConfig::default(),
102        }
103    }
104
105    /// Sets whether step spans are created (metrics still flow when off).
106    pub fn with_spans_enabled(mut self, enabled: bool) -> Self {
107        self.spans_enabled = enabled;
108        self
109    }
110
111    /// Sets the per-family metric levers. The error family ignores them.
112    pub fn with_metric_levers(mut self, levers: MetricsLeversConfig) -> Self {
113        self.metric_levers = levers;
114        self
115    }
116
117    /// Metrics-only fast path (`spans_enabled = false`): no OTel span, no
118    /// local tracing span, context passes through untouched; metric
119    /// families are recorded per the levers.
120    fn call_metrics_only(
121        &mut self,
122        exchange: Exchange,
123        start: Instant,
124    ) -> Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>> {
125        let fresh = self.inner.clone();
126        let mut inner = std::mem::replace(&mut self.inner, fresh);
127        let metrics = self.metrics.clone();
128        let route_id = self.route_id.clone();
129        let levers = self.metric_levers.clone();
130        Box::pin(async move {
131            let result = inner.call(exchange).await;
132            record_step_metrics(
133                metrics.as_ref(),
134                &route_id,
135                &levers,
136                start.elapsed(),
137                &result,
138                true,
139            );
140            result
141        })
142    }
143}
144
145/// Emits the step metric families per the levers: `record_exchange_duration`
146/// only when the duration family is enabled AND the attempt is a call-time
147/// attempt (`include_duration`), `increment_exchanges` only when the exchange
148/// family is enabled, and `increment_errors` NEVER gated
149/// (metrics-configuration Req 2). Circuit-open rejections are excluded here
150/// as well (dashboard-observability D2): the breaker counts them.
151///
152/// Readiness-phase attempts pass `include_duration = false`: the duration
153/// histogram population is call-time only (ADR-0066 population contracts),
154/// while the exchange count and error increment are required on the
155/// readiness path too (rc-mn8n).
156fn record_step_metrics(
157    metrics: Option<&Arc<dyn MetricsCollector>>,
158    route_id: &str,
159    levers: &MetricsLeversConfig,
160    duration: std::time::Duration,
161    result: &Result<Exchange, CamelError>,
162    include_duration: bool,
163) {
164    let Some(metrics) = metrics else { return };
165    if include_duration && levers.durations_enabled() {
166        metrics.record_exchange_duration(route_id, duration);
167    }
168    if levers.exchanges_enabled() {
169        metrics.increment_exchanges(route_id);
170    }
171    if let Err(e) = result {
172        let error_class = e.classify();
173        if error_class != CIRCUIT_OPEN {
174            // allow-open-label rc-otxh (classify() returns a closed &'static str set: exhaustive match in CamelError::classify)
175            metrics.increment_errors(route_id, error_class);
176        }
177    }
178}
179
180impl Service<Exchange> for TracingProcessor {
181    type Response = Exchange;
182    type Error = CamelError;
183    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
184
185    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
186        let start = Instant::now();
187        match self.inner.poll_ready(cx) {
188            Poll::Ready(Ok(())) => Poll::Ready(Ok(())),
189            Poll::Pending => Poll::Pending,
190            Poll::Ready(Err(e)) => {
191                // rc-mn8n: readiness-phase producer failures (e.g.
192                // DirectProducer with the default failIfNoConsumers=true)
193                // surface here, BEFORE the traced call — so the call-time
194                // Err arm never runs for them. Record the exchange count
195                // and the error class with the same labels (CIRCUIT_OPEN
196                // still excluded — the breaker counts its own rejections).
197                // Duration is NOT recorded here: the
198                // camel_exchange_duration_seconds population stays
199                // call-time only (ADR-0066 population contracts).
200                record_step_metrics(
201                    self.metrics.as_ref(),
202                    &self.route_id,
203                    &self.metric_levers,
204                    start.elapsed(),
205                    &Err(e.clone()),
206                    false,
207                );
208                Poll::Ready(Err(e))
209            }
210        }
211    }
212
213    fn call(&mut self, mut exchange: Exchange) -> Self::Future {
214        let start = Instant::now();
215
216        // Metrics-only mode (dashboard-observability D3): spans are gated by
217        // `tracer.enabled`, but the pipeline adapter still runs so metric
218        // families — errors unconditionally — keep flowing.
219        if !self.spans_enabled {
220            return self.call_metrics_only(exchange, start);
221        }
222
223        let span_name = self.span_name.clone();
224        let span_kind = self.span_kind.clone();
225
226        // Get the global tracer (noop if no provider is configured)
227        let tracer = global::tracer_with_scope(
228            InstrumentationScope::builder("camel-core")
229                .with_version(env!("CARGO_PKG_VERSION"))
230                .build(),
231        );
232
233        // Extract parent context from exchange.otel_context
234        let parent_cx = exchange.otel_context.clone();
235
236        // Build span attributes (Minimal set; Medium/Full extras appended below)
237        let mut attributes =
238            step_span_attributes(&self.route_id, self.step_index, exchange.correlation_id());
239
240        if self.detail_level >= DetailLevel::Medium {
241            attributes.push(KeyValue::new(
242                "headers_count",
243                exchange.input.headers.len() as i64,
244            ));
245            attributes.push(KeyValue::new(
246                "body_type",
247                body_type_name(&exchange.input.body),
248            ));
249            attributes.push(KeyValue::new("has_error", exchange.has_error()));
250        }
251
252        // Start a new span as a child of the parent context
253        let span = tracer
254            .span_builder(span_name)
255            .with_kind(span_kind)
256            .with_attributes(attributes.iter().cloned())
257            .start_with_context(&tracer, &parent_cx);
258
259        // Derive the step context from the parent (not from the ambient
260        // current context): parent entries such as baggage stay attached, and
261        // the parent context is restored on the result exchange after the step.
262        let cx = parent_cx.with_span(span);
263
264        // Store back into exchange so downstream processors inherit this context
265        exchange.otel_context = cx.clone();
266
267        // Also create a tracing span for local dev logging
268        let tracing_span = tracing::info_span!(
269            target: "camel_tracer",
270            "step",
271            correlation_id = %exchange.correlation_id(),
272            route_id = %self.route_id,
273            step_id = %self.step_id,
274            step_index = self.step_index,
275            duration_ms = tracing::field::Empty,
276            status = tracing::field::Empty,
277            headers_count = tracing::field::Empty,
278            body_type = tracing::field::Empty,
279            has_error = tracing::field::Empty,
280            output_body_type = tracing::field::Empty,
281            header_0 = tracing::field::Empty,
282            header_1 = tracing::field::Empty,
283            header_2 = tracing::field::Empty,
284            error = tracing::field::Empty,
285            error_type = tracing::field::Empty,
286        );
287
288        if self.detail_level >= DetailLevel::Medium {
289            tracing_span.record("headers_count", exchange.input.headers.len() as u64);
290            tracing_span.record("body_type", body_type_name(&exchange.input.body));
291            tracing_span.record("has_error", exchange.has_error());
292        }
293
294        if self.detail_level >= DetailLevel::Full {
295            let headers: Vec<_> = exchange.input.headers.iter().take(3).collect();
296            if let Some((k, v)) = headers.first() {
297                tracing_span.record("header_0", format!("{k}={v:?}"));
298            }
299            if let Some((k, v)) = headers.get(1) {
300                tracing_span.record("header_1", format!("{k}={v:?}"));
301            }
302            if let Some((k, v)) = headers.get(2) {
303                tracing_span.record("header_2", format!("{k}={v:?}"));
304            }
305        }
306
307        // Consume the ORIGINAL inner that `poll_ready` readied: its
308        // reservations (e.g. DirectProducer's pending semaphore permit)
309        // belong to that instance, so re-readying a clone would drop them.
310        // The fresh clone stays in `self.inner` as the unreadied placeholder
311        // for the next ready/call cycle.
312        let fresh = self.inner.clone();
313        let mut inner = std::mem::replace(&mut self.inner, fresh);
314        let detail_level = self.detail_level.clone();
315        let metrics = self.metrics.clone();
316        let route_id = self.route_id.clone();
317        let levers = self.metric_levers.clone();
318
319        Box::pin(
320            async move {
321                // Note: ContextGuard is not Send (it uses thread-local storage), so we cannot
322                // hold it across await points in an async fn. Instead, we propagate the OTel
323                // context through exchange.otel_context, which is Send + Sync.
324
325                // Create guard to ensure span is ended even on panic
326                let _guard = SpanEndGuard(cx.clone());
327
328                let result = inner.call(exchange).await;
329
330                let duration = start.elapsed();
331                let duration_ms = duration.as_millis() as u64;
332                tracing::Span::current().record("duration_ms", duration_ms);
333
334                // Record metric families per the levers (errors never gated).
335                record_step_metrics(
336                    metrics.as_ref(),
337                    &route_id,
338                    &levers,
339                    duration,
340                    &result,
341                    true,
342                );
343
344                match result {
345                    Ok(mut ex) => {
346                        tracing::Span::current().record("status", "success");
347                        cx.span().set_status(Status::Ok);
348
349                        if detail_level >= DetailLevel::Medium {
350                            tracing::Span::current()
351                                .record("output_body_type", body_type_name(&ex.input.body));
352                            cx.span().set_attribute(KeyValue::new(
353                                "output_body_type",
354                                body_type_name(&ex.input.body),
355                            ));
356                        }
357
358                        // Restore the caller's context: the step span ends with
359                        // this future, so downstream steps must not chain onto it.
360                        ex.otel_context = parent_cx.clone();
361                        Ok(ex)
362                    }
363                    Err(e) => {
364                        record_exception(&cx.span(), &e);
365                        let error_class = e.classify();
366                        tracing::Span::current().record("status", "error");
367                        tracing::Span::current().record("error", e.to_string());
368                        tracing::Span::current().record("error_type", error_class);
369                        Err(e)
370                    }
371                }
372            }
373            .instrument(tracing_span),
374        )
375    }
376}
377
378impl Clone for TracingProcessor {
379    fn clone(&self) -> Self {
380        Self {
381            inner: self.inner.clone(),
382            route_id: self.route_id.clone(),
383            step_id: self.step_id.clone(),
384            span_name: self.span_name.clone(),
385            step_index: self.step_index,
386            detail_level: self.detail_level.clone(),
387            metrics: self.metrics.clone(),
388            span_kind: self.span_kind.clone(),
389            spans_enabled: self.spans_enabled,
390            metric_levers: self.metric_levers.clone(),
391        }
392    }
393}
394
395/// R4-L8: cap only the span-attr representation. Exchange.correlation_id is untouched.
396pub(crate) fn capped_correlation_id(id: &str) -> &str {
397    const CAP: usize = 128;
398    if id.len() > CAP {
399        "<oversized:correlation_id>"
400    } else {
401        id
402    }
403}
404
405/// Minimal-level span attributes for a pipeline step span.
406///
407/// `correlation_id` is the raw exchange correlation id; this is the single
408/// capping site for its span-attribute representation (R4-L8). Medium/Full
409/// extras (`headers_count`, `body_type`, `has_error`) are appended by the
410/// caller, not here.
411pub(crate) fn step_span_attributes(
412    route_id: &str,
413    step_index: usize,
414    correlation_id: &str,
415) -> Vec<KeyValue> {
416    vec![
417        KeyValue::new("messaging.system", "camel"),
418        KeyValue::new(
419            "correlation_id",
420            capped_correlation_id(correlation_id).to_string(),
421        ),
422        KeyValue::new("route_id", route_id.to_string()),
423        KeyValue::new("step_index", step_index as i64),
424    ]
425}
426
427pub(crate) fn record_exception(span: &SpanRef<'_>, e: &CamelError) {
428    let error_class = e.classify();
429    span.set_status(Status::error(e.to_string()));
430    span.add_event(
431        "exception",
432        vec![
433            KeyValue::new("exception.type", error_class.to_string()),
434            KeyValue::new("exception.message", e.to_string()),
435        ],
436    );
437}
438
439#[cfg(test)]
440#[path = "tracer_tests.rs"]
441mod tests;