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