Skip to main content

camel_cli/commands/
bench_instrument.rs

1//! Benchmark timing injection for YAML-loaded routes.
2//!
3//! When `BENCH_LATENCY_FILE` env var is set, every top-level `BuilderStep::To(_)`
4//! in each route definition is wrapped with two processors:
5//!
6//! 1. **Pre**: stores `Arc<Instant::now()>` in `exchange.extensions["BenchStart"]`
7//! 2. **Post**: reads it back, computes `elapsed().as_nanos()`, writes
8//!    `BENCH_LATENCY <id> <duration_ns> <route_id> <encoded_to_uri>` to the
9//!    shared file. The `route_id` and percent-encoded `to_uri` are appended so
10//!    raw logs are attributable; the first two fields are unchanged so the
11//!    benchmark harness (`run.sh` regex) and loadgen parser keep working.
12//!
13//! Per-pair `Arc<AtomicU64>` counter guarantees coherent ids. The latency
14//! file is opened once at injection time and shared via `Arc<Mutex<File>>`
15//! (no per-tick reopen). When the env var is unset, this module is a no-op.
16//!
17//! Only top-level `To` steps are wrapped — nested steps inside `Choice`,
18//! `Split`, `Filter` etc. are left untouched (wrapping those would measure
19//! per-sub-message latency, not per-tick bridge tax).
20
21use std::fs::{File, OpenOptions};
22use std::io::Write;
23use std::sync::Arc;
24use std::sync::Mutex;
25use std::sync::atomic::{AtomicU64, Ordering};
26use std::time::Instant;
27
28use camel_api::{BoxProcessor, BoxProcessorExt, Exchange, OpaqueProcessor};
29use camel_core::{BuilderStep, RouteDefinition};
30use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
31
32/// Encode only whitespace and control chars in the `to_uri` field so the
33/// `BENCH_LATENCY` line stays whitespace-delimited while remaining readable
34/// (`sql:noop?ds=c` passes through unchanged; `http:host?q=a b` → `...a%20b`).
35const BENCH_URI_SAFE: &AsciiSet = &CONTROLS.add(b' ');
36
37/// Extension key under which the pre-`.to()` `Instant` is stored.
38const BENCH_START: &str = "BenchStart";
39
40/// If `BENCH_LATENCY_FILE` is set, instrument each route by wrapping
41/// top-level `To` steps with timing processors. Returns defs unchanged
42/// when the env var is absent (zero-cost no-op).
43pub fn maybe_instrument_routes(defs: Vec<RouteDefinition>) -> Vec<RouteDefinition> {
44    let Ok(path) = std::env::var("BENCH_LATENCY_FILE") else {
45        return defs;
46    };
47
48    let file = match OpenOptions::new().create(true).append(true).open(&path) {
49        Ok(f) => f,
50        Err(e) => {
51            // log-policy: system-broken
52            tracing::error!("bench_instrument: cannot open BENCH_LATENCY_FILE '{path}': {e}");
53            return defs;
54        }
55    };
56    let shared_file = Arc::new(Mutex::new(file));
57
58    tracing::info!("bench_instrument: wrapping top-level To steps (file={path})");
59
60    defs.into_iter()
61        .map(|def| {
62            let sf = Arc::clone(&shared_file);
63            let route_id = def.route_id().to_string();
64            def.map_steps(|steps| inject_timing(steps, route_id, sf))
65        })
66        .collect()
67}
68
69/// Walk the flat step list and insert pre/post processors around each `To`.
70fn inject_timing(
71    steps: Vec<BuilderStep>,
72    route_id: String,
73    file: Arc<Mutex<File>>,
74) -> Vec<BuilderStep> {
75    let mut result = Vec::with_capacity(steps.len() * 3);
76    for step in steps {
77        if let BuilderStep::To(uri) = step {
78            let counter = Arc::new(AtomicU64::new(0)); // per-pair: ids unique within one To step
79            result.push(make_start_processor());
80            result.push(BuilderStep::To(uri.clone()));
81            result.push(make_end_processor(
82                route_id.clone(),
83                uri,
84                counter,
85                Arc::clone(&file),
86            ));
87        } else {
88            result.push(step);
89        }
90    }
91    result
92}
93
94/// Create a processor that stamps `Instant::now()` into the exchange extension.
95fn make_start_processor() -> BuilderStep {
96    BuilderStep::Processor(OpaqueProcessor(BoxProcessor::from_fn(
97        |mut exchange: Exchange| {
98            Box::pin(async move {
99                exchange.set_extension(BENCH_START, Arc::new(Instant::now()));
100                Ok(exchange)
101            })
102        },
103    )))
104}
105
106/// Create a processor that reads the stored `Instant` (via `Arc<dyn Any>`
107/// downcast to `Instant`), computes the delta, and appends
108/// `BENCH_LATENCY <id> <ns> <route_id> <encoded_uri>` to the shared file.
109fn make_end_processor(
110    route_id: String,
111    uri: String,
112    counter: Arc<AtomicU64>,
113    file: Arc<Mutex<File>>,
114) -> BuilderStep {
115    BuilderStep::Processor(OpaqueProcessor(BoxProcessor::from_fn(
116        move |exchange: Exchange| {
117            let counter = Arc::clone(&counter);
118            let file = Arc::clone(&file);
119            let route_id = route_id.clone();
120            let uri = uri.clone();
121            Box::pin(async move {
122                let id = counter.fetch_add(1, Ordering::Relaxed) + 1;
123                let duration_ns = exchange
124                    .get_extension::<Instant>(BENCH_START)
125                    .map(|t| t.elapsed().as_nanos() as u64)
126                    .unwrap_or(0);
127                let line = format_bench_line(&route_id, &uri, id, duration_ns);
128                if let Ok(mut f) = file.lock() {
129                    let _ = f.write_all(line.as_bytes());
130                }
131                Ok(exchange)
132            })
133        },
134    )))
135}
136
137/// Compose one `BENCH_LATENCY` record line.
138///
139/// Format: `BENCH_LATENCY <tick_id> <duration_ns> <route_id> <encoded_uri>`.
140/// The first two fields are unchanged from the original contract so the
141/// benchmark harness (`run.sh` regex) and the loadgen parser
142/// (`protocol_b::parse_line`, which ignores trailing tokens) keep working.
143/// The `route_id` and percent-encoded `to_uri` are appended so raw logs are
144/// attributable to a specific route and endpoint. An empty `route_id`
145/// collapses to `-` to keep the field count stable for whitespace-delimited
146/// consumers.
147fn format_bench_line(route_id: &str, uri: &str, id: u64, duration_ns: u64) -> String {
148    let route = if route_id.trim().is_empty() {
149        "-"
150    } else {
151        route_id
152    };
153    let encoded = utf8_percent_encode(uri, BENCH_URI_SAFE);
154    format!("BENCH_LATENCY {id} {duration_ns} {route} {encoded}\n")
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use std::fs::File;
161
162    #[test]
163    fn inject_timing_wraps_each_top_level_to() {
164        let tmp = tempfile::NamedTempFile::new().unwrap();
165        let file = Arc::new(Mutex::new(File::create(tmp.path()).unwrap()));
166
167        let steps = vec![
168            BuilderStep::To("xslt:a".into()),
169            BuilderStep::Stop,
170            BuilderStep::To("xslt:b".into()),
171        ];
172        let out = inject_timing(steps, "test-route".to_string(), file);
173
174        // 2 To steps × 3 (pre + To + post) + 1 Stop = 7
175        assert_eq!(out.len(), 7);
176        assert!(matches!(out[0], BuilderStep::Processor(_)));
177        assert!(matches!(out[1], BuilderStep::To(_)));
178        assert!(matches!(out[2], BuilderStep::Processor(_)));
179        assert!(matches!(out[3], BuilderStep::Stop));
180        assert!(matches!(out[4], BuilderStep::Processor(_)));
181        assert!(matches!(out[5], BuilderStep::To(_)));
182        assert!(matches!(out[6], BuilderStep::Processor(_)));
183    }
184
185    #[test]
186    fn inject_timing_skips_non_to_steps() {
187        let tmp = tempfile::NamedTempFile::new().unwrap();
188        let file = Arc::new(Mutex::new(File::create(tmp.path()).unwrap()));
189
190        let steps = vec![BuilderStep::Stop, BuilderStep::Stop];
191        let out = inject_timing(steps, "test-route".to_string(), file);
192        assert_eq!(out.len(), 2);
193    }
194
195    #[test]
196    fn maybe_instrument_routes_noop_when_env_unset() {
197        // SAFETY: test is single-threaded, no other code reads this env var
198        // during the test.
199        unsafe {
200            std::env::remove_var("BENCH_LATENCY_FILE");
201        }
202        let def = camel_core::RouteDefinition::new(
203            "direct:test".to_string(),
204            vec![BuilderStep::To("mock:a".into())],
205        );
206        let defs = vec![def];
207        let out = maybe_instrument_routes(defs);
208        assert_eq!(out[0].steps().len(), 1);
209    }
210
211    #[test]
212    fn format_bench_line_emits_route_id_and_percent_encoded_uri() {
213        // URI without spaces passes through readable; route_id preserved verbatim.
214        assert_eq!(
215            format_bench_line("nacional-chain", "sql:noop?ds=cartodb", 1, 821_852_517),
216            "BENCH_LATENCY 1 821852517 nacional-chain sql:noop?ds=cartodb\n"
217        );
218        // Spaces inside the URI are percent-encoded so the line stays
219        // whitespace-delimited (the loadgen parser splits on whitespace).
220        assert_eq!(
221            format_bench_line("r2", "http:host?q=a b", 3, 1000),
222            "BENCH_LATENCY 3 1000 r2 http:host?q=a%20b\n"
223        );
224        // Empty route_id collapses to a placeholder so the field count is
225        // stable for whitespace-delimited consumers.
226        assert_eq!(
227            format_bench_line("", "direct:foo", 2, 500),
228            "BENCH_LATENCY 2 500 - direct:foo\n"
229        );
230    }
231}