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            );
139            result
140        })
141    }
142}
143
144/// Emits the step metric families per the levers: `record_exchange_duration`
145/// only when the duration family is enabled, `increment_exchanges` only when
146/// the exchange family is enabled, and `increment_errors` NEVER gated
147/// (metrics-configuration Req 2). Circuit-open rejections are excluded here
148/// as well (dashboard-observability D2): the breaker counts them.
149fn record_step_metrics(
150    metrics: Option<&Arc<dyn MetricsCollector>>,
151    route_id: &str,
152    levers: &MetricsLeversConfig,
153    duration: std::time::Duration,
154    result: &Result<Exchange, CamelError>,
155) {
156    let Some(metrics) = metrics else { return };
157    if levers.durations_enabled() {
158        metrics.record_exchange_duration(route_id, duration);
159    }
160    if levers.exchanges_enabled() {
161        metrics.increment_exchanges(route_id);
162    }
163    if let Err(e) = result {
164        let error_class = e.classify();
165        if error_class != CIRCUIT_OPEN {
166            // allow-open-label rc-otxh (classify() returns a closed &'static str set: exhaustive match in CamelError::classify)
167            metrics.increment_errors(route_id, error_class);
168        }
169    }
170}
171
172impl Service<Exchange> for TracingProcessor {
173    type Response = Exchange;
174    type Error = CamelError;
175    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
176
177    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
178        self.inner.poll_ready(cx)
179    }
180
181    fn call(&mut self, mut exchange: Exchange) -> Self::Future {
182        let start = Instant::now();
183
184        // Metrics-only mode (dashboard-observability D3): spans are gated by
185        // `tracer.enabled`, but the pipeline adapter still runs so metric
186        // families — errors unconditionally — keep flowing.
187        if !self.spans_enabled {
188            return self.call_metrics_only(exchange, start);
189        }
190
191        let span_name = self.span_name.clone();
192        let span_kind = self.span_kind.clone();
193
194        // Get the global tracer (noop if no provider is configured)
195        let tracer = global::tracer_with_scope(
196            InstrumentationScope::builder("camel-core")
197                .with_version(env!("CARGO_PKG_VERSION"))
198                .build(),
199        );
200
201        // Extract parent context from exchange.otel_context
202        let parent_cx = exchange.otel_context.clone();
203
204        // Build span attributes (Minimal set; Medium/Full extras appended below)
205        let mut attributes =
206            step_span_attributes(&self.route_id, self.step_index, exchange.correlation_id());
207
208        if self.detail_level >= DetailLevel::Medium {
209            attributes.push(KeyValue::new(
210                "headers_count",
211                exchange.input.headers.len() as i64,
212            ));
213            attributes.push(KeyValue::new(
214                "body_type",
215                body_type_name(&exchange.input.body),
216            ));
217            attributes.push(KeyValue::new("has_error", exchange.has_error()));
218        }
219
220        // Start a new span as a child of the parent context
221        let span = tracer
222            .span_builder(span_name)
223            .with_kind(span_kind)
224            .with_attributes(attributes.iter().cloned())
225            .start_with_context(&tracer, &parent_cx);
226
227        // Derive the step context from the parent (not from the ambient
228        // current context): parent entries such as baggage stay attached, and
229        // the parent context is restored on the result exchange after the step.
230        let cx = parent_cx.with_span(span);
231
232        // Store back into exchange so downstream processors inherit this context
233        exchange.otel_context = cx.clone();
234
235        // Also create a tracing span for local dev logging
236        let tracing_span = tracing::info_span!(
237            target: "camel_tracer",
238            "step",
239            correlation_id = %exchange.correlation_id(),
240            route_id = %self.route_id,
241            step_id = %self.step_id,
242            step_index = self.step_index,
243            duration_ms = tracing::field::Empty,
244            status = tracing::field::Empty,
245            headers_count = tracing::field::Empty,
246            body_type = tracing::field::Empty,
247            has_error = tracing::field::Empty,
248            output_body_type = tracing::field::Empty,
249            header_0 = tracing::field::Empty,
250            header_1 = tracing::field::Empty,
251            header_2 = tracing::field::Empty,
252            error = tracing::field::Empty,
253            error_type = tracing::field::Empty,
254        );
255
256        if self.detail_level >= DetailLevel::Medium {
257            tracing_span.record("headers_count", exchange.input.headers.len() as u64);
258            tracing_span.record("body_type", body_type_name(&exchange.input.body));
259            tracing_span.record("has_error", exchange.has_error());
260        }
261
262        if self.detail_level >= DetailLevel::Full {
263            let headers: Vec<_> = exchange.input.headers.iter().take(3).collect();
264            if let Some((k, v)) = headers.first() {
265                tracing_span.record("header_0", format!("{k}={v:?}"));
266            }
267            if let Some((k, v)) = headers.get(1) {
268                tracing_span.record("header_1", format!("{k}={v:?}"));
269            }
270            if let Some((k, v)) = headers.get(2) {
271                tracing_span.record("header_2", format!("{k}={v:?}"));
272            }
273        }
274
275        // Consume the ORIGINAL inner that `poll_ready` readied: its
276        // reservations (e.g. DirectProducer's pending semaphore permit)
277        // belong to that instance, so re-readying a clone would drop them.
278        // The fresh clone stays in `self.inner` as the unreadied placeholder
279        // for the next ready/call cycle.
280        let fresh = self.inner.clone();
281        let mut inner = std::mem::replace(&mut self.inner, fresh);
282        let detail_level = self.detail_level.clone();
283        let metrics = self.metrics.clone();
284        let route_id = self.route_id.clone();
285        let levers = self.metric_levers.clone();
286
287        Box::pin(
288            async move {
289                // Note: ContextGuard is not Send (it uses thread-local storage), so we cannot
290                // hold it across await points in an async fn. Instead, we propagate the OTel
291                // context through exchange.otel_context, which is Send + Sync.
292
293                // Create guard to ensure span is ended even on panic
294                let _guard = SpanEndGuard(cx.clone());
295
296                let result = inner.call(exchange).await;
297
298                let duration = start.elapsed();
299                let duration_ms = duration.as_millis() as u64;
300                tracing::Span::current().record("duration_ms", duration_ms);
301
302                // Record metric families per the levers (errors never gated).
303                record_step_metrics(metrics.as_ref(), &route_id, &levers, duration, &result);
304
305                match result {
306                    Ok(mut ex) => {
307                        tracing::Span::current().record("status", "success");
308                        cx.span().set_status(Status::Ok);
309
310                        if detail_level >= DetailLevel::Medium {
311                            tracing::Span::current()
312                                .record("output_body_type", body_type_name(&ex.input.body));
313                            cx.span().set_attribute(KeyValue::new(
314                                "output_body_type",
315                                body_type_name(&ex.input.body),
316                            ));
317                        }
318
319                        // Restore the caller's context: the step span ends with
320                        // this future, so downstream steps must not chain onto it.
321                        ex.otel_context = parent_cx.clone();
322                        Ok(ex)
323                    }
324                    Err(e) => {
325                        record_exception(&cx.span(), &e);
326                        let error_class = e.classify();
327                        tracing::Span::current().record("status", "error");
328                        tracing::Span::current().record("error", e.to_string());
329                        tracing::Span::current().record("error_type", error_class);
330                        Err(e)
331                    }
332                }
333            }
334            .instrument(tracing_span),
335        )
336    }
337}
338
339impl Clone for TracingProcessor {
340    fn clone(&self) -> Self {
341        Self {
342            inner: self.inner.clone(),
343            route_id: self.route_id.clone(),
344            step_id: self.step_id.clone(),
345            span_name: self.span_name.clone(),
346            step_index: self.step_index,
347            detail_level: self.detail_level.clone(),
348            metrics: self.metrics.clone(),
349            span_kind: self.span_kind.clone(),
350            spans_enabled: self.spans_enabled,
351            metric_levers: self.metric_levers.clone(),
352        }
353    }
354}
355
356/// R4-L8: cap only the span-attr representation. Exchange.correlation_id is untouched.
357pub(crate) fn capped_correlation_id(id: &str) -> &str {
358    const CAP: usize = 128;
359    if id.len() > CAP {
360        "<oversized:correlation_id>"
361    } else {
362        id
363    }
364}
365
366/// Minimal-level span attributes for a pipeline step span.
367///
368/// `correlation_id` is the raw exchange correlation id; this is the single
369/// capping site for its span-attribute representation (R4-L8). Medium/Full
370/// extras (`headers_count`, `body_type`, `has_error`) are appended by the
371/// caller, not here.
372pub(crate) fn step_span_attributes(
373    route_id: &str,
374    step_index: usize,
375    correlation_id: &str,
376) -> Vec<KeyValue> {
377    vec![
378        KeyValue::new("messaging.system", "camel"),
379        KeyValue::new(
380            "correlation_id",
381            capped_correlation_id(correlation_id).to_string(),
382        ),
383        KeyValue::new("route_id", route_id.to_string()),
384        KeyValue::new("step_index", step_index as i64),
385    ]
386}
387
388pub(crate) fn record_exception(span: &SpanRef<'_>, e: &CamelError) {
389    let error_class = e.classify();
390    span.set_status(Status::error(e.to_string()));
391    span.add_event(
392        "exception",
393        vec![
394            KeyValue::new("exception.type", error_class.to_string()),
395            KeyValue::new("exception.message", e.to_string()),
396        ],
397    );
398}
399
400#[cfg(test)]
401#[path = "tracer_tests.rs"]
402mod tests;