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