1use camel_api::{CamelError, Exchange, IdentityProcessor, Value, ValueSource};
2use std::future::Future;
3use std::pin::Pin;
4use std::task::{Context, Poll};
5use tower::Service;
6use tracing::{debug, error, info, trace, warn};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum LogLevel {
10 Trace,
11 Debug,
12 Info,
13 Warn,
14 Error,
15}
16
17#[derive(Clone)]
18pub struct LogProcessor {
19 inner: IdentityProcessor,
20 level: LogLevel,
21 message: String,
22}
23
24impl LogProcessor {
29 pub fn new(level: LogLevel, message: String) -> Self {
30 Self {
31 inner: IdentityProcessor,
32 level,
33 message,
34 }
35 }
36}
37
38impl Service<Exchange> for LogProcessor {
39 type Response = Exchange;
40 type Error = CamelError;
41 type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
42
43 fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
44 self.inner.poll_ready(cx)
45 }
46
47 fn call(&mut self, exchange: Exchange) -> Self::Future {
48 let msg = self.message.clone();
49 let exchange_id = exchange.correlation_id.clone();
50 let body_preview = sanitize_preview(
51 &exchange
52 .input
53 .body
54 .as_text()
55 .unwrap_or("")
56 .chars()
57 .take(64)
58 .collect::<String>(),
59 );
60 debug!(exchange_id = %exchange_id, body_preview = %body_preview, "LogProcessor processing exchange");
61 match self.level {
62 LogLevel::Trace => trace!(exchange_id = %exchange_id, "{}", msg),
63 LogLevel::Debug => debug!(exchange_id = %exchange_id, "{}", msg),
64 LogLevel::Info => info!(exchange_id = %exchange_id, "{}", msg),
65 LogLevel::Warn => warn!(exchange_id = %exchange_id, "{}", msg),
66 LogLevel::Error => error!(exchange_id = %exchange_id, "{}", msg),
71 }
72 self.inner.call(exchange)
73 }
74}
75
76#[derive(Clone)]
82pub struct DynamicLog {
83 inner: IdentityProcessor,
84 level: LogLevel,
85 source: ValueSource,
86}
87
88impl DynamicLog {
89 pub fn new(level: LogLevel, source: impl Into<ValueSource>) -> Self {
90 Self {
91 inner: IdentityProcessor,
92 level,
93 source: source.into(),
94 }
95 }
96}
97
98fn log_value_to_string(value: Value) -> String {
101 match value {
102 Value::String(s) => s,
103 other => other.to_string(),
104 }
105}
106
107impl Service<Exchange> for DynamicLog {
108 type Response = Exchange;
109 type Error = CamelError;
110 type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
111
112 fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
113 self.inner.poll_ready(cx)
114 }
115
116 fn call(&mut self, exchange: Exchange) -> Self::Future {
117 let source = self.source.clone();
118 let level = self.level;
119 Box::pin(async move {
120 let value = source.evaluate(&exchange).await?;
121 let exchange_id = exchange.correlation_id.clone();
122 let msg = sanitize_preview(&log_value_to_string(value));
123 match level {
124 LogLevel::Trace => trace!(exchange_id = %exchange_id, "{}", msg),
125 LogLevel::Debug => debug!(exchange_id = %exchange_id, "{}", msg),
126 LogLevel::Info => info!(exchange_id = %exchange_id, "{}", msg),
127 LogLevel::Warn => warn!(exchange_id = %exchange_id, "{}", msg),
128 LogLevel::Error => error!(exchange_id = %exchange_id, "{}", msg),
131 }
132 Ok(exchange)
133 })
134 }
135}
136
137fn sanitize_preview(s: &str) -> String {
141 s.chars()
142 .map(|c| if c.is_control() { '\u{FFFD}' } else { c })
143 .collect()
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149 use camel_api::body::Body;
150 use camel_api::{Message, Value};
151 use tower::ServiceExt;
152
153 #[tokio::test]
154 async fn test_log_processor_passes_exchange_through() {
155 let mut processor = LogProcessor::new(LogLevel::Info, "test message".into());
156 let exchange = Exchange::default();
157 let result = processor.call(exchange).await;
158 assert!(result.is_ok());
159 }
160
161 #[tokio::test]
162 async fn test_log_processor_preserves_exchange_body() {
163 let mut processor = LogProcessor::new(LogLevel::Debug, "debug message".into());
164 let mut exchange = Exchange::default();
165 exchange.input.body = Body::Text("test body".into());
166 let result = processor.call(exchange).await.unwrap();
167 assert_eq!(result.input.body.as_text(), Some("test body"));
168 }
169
170 fn sync_source<F>(f: F) -> camel_api::ValueSource
171 where
172 F: Fn(&Exchange) -> camel_api::Value + Send + Sync + 'static,
173 {
174 camel_api::ValueSource::Sync(std::sync::Arc::new(f))
175 }
176
177 #[tokio::test]
178 async fn test_dynamic_log_evaluates_body() {
179 let svc = DynamicLog::new(
180 LogLevel::Info,
181 sync_source(|ex: &Exchange| {
182 camel_api::Value::String(format!("body={}", ex.input.body.as_text().unwrap_or("")))
183 }),
184 );
185 let exchange = Exchange::new(Message::new("hello"));
186 let result = svc.oneshot(exchange).await.unwrap();
187 assert_eq!(result.input.body.as_text(), Some("hello"));
189 }
190
191 #[tokio::test]
192 async fn test_dynamic_log_evaluates_header() {
193 let svc = DynamicLog::new(
194 LogLevel::Info,
195 sync_source(|ex: &Exchange| {
196 let counter = ex
197 .input
198 .header("CamelTimerCounter")
199 .and_then(|v| v.as_i64())
200 .unwrap_or(0);
201 camel_api::Value::String(format!("{} World", counter))
202 }),
203 );
204 let mut msg = Message::new("");
205 msg.set_header("CamelTimerCounter", Value::Number(42.into()));
206 let exchange = Exchange::new(msg);
207 let result = svc.oneshot(exchange).await.unwrap();
208 assert_eq!(
210 result.input.header("CamelTimerCounter"),
211 Some(&Value::Number(42.into()))
212 );
213 }
214
215 #[tokio::test]
216 async fn dynamic_log_error_fails_step() {
217 use std::sync::Arc;
218
219 use camel_api::{BoxValueFuture, ValueSource};
220
221 let source = ValueSource::Async(Arc::new(|_: &Exchange| {
222 Box::pin(async { Err(CamelError::ProcessorError("log boom".into())) }) as BoxValueFuture
223 }));
224 let svc = DynamicLog::new(LogLevel::Info, source);
225
226 let result = svc.oneshot(Exchange::new(Message::new("x"))).await;
227 assert!(
228 result.is_err(),
229 "a failed log-message expression must fail the step, not log a null message"
230 );
231 }
232
233 #[test]
234 fn body_preview_strips_control_chars() {
235 let body_with_injection = "no\ttab\nnl\rcr\0null\u{1B}esc";
236 let preview: String = body_with_injection.chars().take(64).collect();
237 let sanitized = sanitize_preview(&preview);
238 assert!(!sanitized.contains('\n'), "newlines must be replaced");
239 assert!(
240 !sanitized.contains('\r'),
241 "carriage returns must be replaced"
242 );
243 assert!(!sanitized.contains('\t'), "tabs must be replaced");
244 assert!(!sanitized.contains('\0'), "null must be replaced");
245 assert!(
246 !sanitized.contains('\u{1B}'),
247 "ESC (ANSI injection) must be replaced"
248 );
249 assert!(
251 sanitized.contains('\u{FFFD}'),
252 "control chars must be replaced with U+FFFD, not deleted"
253 );
254 }
255
256 #[tokio::test]
257 async fn dynamic_log_sanitizes_control_chars() {
258 let svc = DynamicLog::new(
259 LogLevel::Info,
260 sync_source(|_ex: &Exchange| {
261 camel_api::Value::String("fake\nline\rinjection".to_string())
262 }),
263 );
264 let exchange = Exchange::default();
268 let result = svc.oneshot(exchange).await;
269 assert!(result.is_ok());
270 }
271
272 fn capture_logs<F: FnOnce()>(f: F) -> String {
274 use std::io::Write;
275 use std::sync::{Arc, Mutex};
276
277 #[derive(Clone)]
278 struct Buf(Arc<Mutex<Vec<u8>>>);
279 impl Write for Buf {
280 fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
281 self.0.lock().unwrap().extend_from_slice(b); Ok(b.len())
283 }
284 fn flush(&mut self) -> std::io::Result<()> {
285 Ok(())
286 }
287 }
288 impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for Buf {
289 type Writer = Buf;
290 fn make_writer(&'a self) -> Buf {
291 self.clone()
292 }
293 }
294
295 let buf = Buf(Arc::new(Mutex::new(Vec::new())));
296 let subscriber = tracing_subscriber::fmt()
297 .with_writer(buf.clone())
298 .with_ansi(false)
299 .with_max_level(tracing::Level::TRACE)
300 .finish();
301 tracing::subscriber::with_default(subscriber, f);
302 let bytes = buf.0.lock().unwrap().clone(); String::from_utf8(bytes).unwrap() }
305
306 #[test]
307 fn log_processor_error_level_emits_error_not_warn() {
308 let out = capture_logs(|| {
309 let mut p = LogProcessor::new(LogLevel::Error, "boom-static".into());
310 drop(p.call(Exchange::default()));
311 });
312 assert!(
313 out.contains("ERROR") && out.contains("boom-static"),
314 "level error must emit ERROR, got: {out}"
315 );
316 assert!(!out.contains("WARN"), "must not downgrade to WARN: {out}");
317 }
318
319 #[test]
320 fn dynamic_log_error_level_emits_error_not_warn() {
321 let out = capture_logs(|| {
322 let mut svc = DynamicLog::new(
323 LogLevel::Error,
324 sync_source(|_ex: &Exchange| camel_api::Value::String("boom-dynamic".into())),
325 );
326 let fut = svc.call(Exchange::default());
327 futures::executor::block_on(fut).unwrap(); });
329 assert!(
330 out.contains("ERROR") && out.contains("boom-dynamic"),
331 "level error must emit ERROR, got: {out}"
332 );
333 assert!(!out.contains("WARN"), "must not downgrade to WARN: {out}");
334 }
335
336 #[test]
337 fn log_processor_warn_level_still_emits_warn() {
338 let out = capture_logs(|| {
339 let mut p = LogProcessor::new(LogLevel::Warn, "w-static".into());
340 drop(p.call(Exchange::default()));
341 });
342 assert!(out.contains("WARN") && !out.contains("ERROR"), "got: {out}");
343 }
344}