Skip to main content

browser_control/cli/
trace.rs

1//! One-line structured trace per CLI dispatch.
2//!
3//! Every CLI command builds a [`CommandTrace`] at entry and finishes it
4//! at exit. The trace is emitted at `tracing::Level::INFO` so it lands
5//! by default (`BROWSER_CONTROL_LOG=info`), with a fixed field schema:
6//!
7//! ```text
8//! command   – static command name (eval / fetch / tab-open / ...)
9//! browser   – resolved registered name, or empty for external URLs
10//! engine    – "cdp" | "bidi" | ""
11//! route     – which code path the command took (scratch / named-tab / ...)
12//! tab_name  – name of the named tab if any
13//! target_id – engine-specific id touched, if known
14//! elapsed_ms – wall-clock duration from CommandTrace::new to .finish*
15//! outcome   – "ok" | "err"
16//! ```
17//!
18//! Agents and operators consume these by tailing stderr and grepping
19//! `target=browser_control::cli`.
20
21use std::time::Instant;
22
23use crate::detect::Engine;
24
25/// Mutable trace builder for a single CLI command invocation.
26///
27/// Construction starts the clock; calling [`CommandTrace::ok`] or
28/// [`CommandTrace::err`] emits the line and consumes the value.
29///
30/// The builder fields are accumulated as the command progresses and
31/// figures out what it's doing (browser resolution, route selection,
32/// tab binding). Anything not set defaults to an empty string in the
33/// emitted log line so the schema is stable.
34pub struct CommandTrace {
35    command: &'static str,
36    start: Instant,
37    browser: String,
38    engine: String,
39    route: &'static str,
40    tab_name: String,
41    target_id: String,
42}
43
44impl CommandTrace {
45    /// Start a trace. The clock starts here. `command` is the static
46    /// command name (`"eval"`, `"fetch"`, `"tab-open"`, …) — keep it
47    /// kebab-cased so log consumers can match on a stable string.
48    pub fn new(command: &'static str) -> Self {
49        Self {
50            command,
51            start: Instant::now(),
52            browser: String::new(),
53            engine: String::new(),
54            route: "",
55            tab_name: String::new(),
56            target_id: String::new(),
57        }
58    }
59
60    pub fn browser(&mut self, s: impl Into<String>) -> &mut Self {
61        self.browser = s.into();
62        self
63    }
64
65    pub fn engine(&mut self, e: Engine) -> &mut Self {
66        self.engine = match e {
67            Engine::Cdp => "cdp".into(),
68            Engine::Bidi => "bidi".into(),
69        };
70        self
71    }
72
73    /// Set the routing path. Suggested values:
74    /// - `"scratch"`        — scratch-tab recovery wrapper
75    /// - `"named-tab"`      — `<browser>/<tab>` via tabs registry
76    /// - `"target-regex"`   — legacy `--target <regex>` selector
77    /// - `"attach-for-origin"` — fetch default (origin-matched tab)
78    /// - `"direct"`         — external URL fall-through
79    /// - `"registry"`       — registry-only ops (cookies, wait, etc.)
80    pub fn route(&mut self, r: &'static str) -> &mut Self {
81        self.route = r;
82        self
83    }
84
85    pub fn tab_name(&mut self, s: impl Into<String>) -> &mut Self {
86        self.tab_name = s.into();
87        self
88    }
89
90    pub fn target_id(&mut self, s: impl Into<String>) -> &mut Self {
91        self.target_id = s.into();
92        self
93    }
94
95    /// Emit the closing log line with `outcome=ok` and return `val`
96    /// unchanged. Use at the success exit of every command:
97    /// `Ok(trace.ok(value))`.
98    pub fn ok<T>(self, val: T) -> T {
99        self.emit("ok", None);
100        val
101    }
102
103    /// Emit the closing log line with `outcome=err` and return `err`
104    /// unchanged. Use at the error exit: `Err(trace.err(e))`.
105    pub fn err(self, err: anyhow::Error) -> anyhow::Error {
106        let msg = format!("{err:#}");
107        self.emit("err", Some(&msg));
108        err
109    }
110
111    /// Combinator collapsing the `match result { Ok => ok; Err => err }`
112    /// boilerplate every command's entry point used to repeat. Emits the
113    /// closing line with the matching outcome and returns `result`
114    /// unchanged: `trace.finish(run_inner(...).await)`.
115    pub fn finish(self, result: anyhow::Result<()>) -> anyhow::Result<()> {
116        match result {
117            Ok(()) => {
118                self.ok(());
119                Ok(())
120            }
121            Err(e) => Err(self.err(e)),
122        }
123    }
124
125    fn emit(&self, outcome: &'static str, err_msg: Option<&str>) {
126        let elapsed_ms = self.start.elapsed().as_millis() as u64;
127        tracing::info!(
128            target: "browser_control::cli",
129            command = self.command,
130            browser = self.browser.as_str(),
131            engine = self.engine.as_str(),
132            route = self.route,
133            tab_name = self.tab_name.as_str(),
134            target_id = self.target_id.as_str(),
135            elapsed_ms,
136            outcome,
137            err = err_msg.unwrap_or(""),
138        );
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use tracing::subscriber::with_default;
146
147    /// Verify the line emits with the expected schema. We capture via a
148    /// tiny custom subscriber rather than depending on `tracing_test`.
149    #[test]
150    fn ok_emits_one_line_with_full_schema() {
151        let captured: std::sync::Arc<std::sync::Mutex<Vec<String>>> =
152            std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
153        let sub = TestSubscriber::new(captured.clone());
154        with_default(sub, || {
155            let mut t = CommandTrace::new("eval");
156            t.browser("brave-twilight")
157                .engine(Engine::Cdp)
158                .route("scratch")
159                .target_id("T42");
160            let _ = t.ok(123);
161        });
162        let lines = captured.lock().unwrap();
163        assert_eq!(lines.len(), 1, "exactly one line emitted");
164        let line = &lines[0];
165        assert!(line.contains("command=\"eval\""), "command field: {line}");
166        assert!(line.contains("browser=\"brave-twilight\""));
167        assert!(line.contains("engine=\"cdp\""));
168        assert!(line.contains("route=\"scratch\""));
169        assert!(line.contains("target_id=\"T42\""));
170        assert!(line.contains("outcome=\"ok\""));
171        assert!(line.contains("elapsed_ms="));
172    }
173
174    #[test]
175    fn err_emits_with_outcome_err_and_msg() {
176        let captured: std::sync::Arc<std::sync::Mutex<Vec<String>>> =
177            std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
178        let sub = TestSubscriber::new(captured.clone());
179        with_default(sub, || {
180            let mut t = CommandTrace::new("fetch");
181            t.browser("chrome-pikachu")
182                .engine(Engine::Cdp)
183                .route("named-tab")
184                .tab_name("scrape-cart");
185            let _ = t.err(anyhow::anyhow!("simulated failure"));
186        });
187        let line = captured.lock().unwrap().pop().unwrap();
188        assert!(line.contains("command=\"fetch\""));
189        assert!(line.contains("outcome=\"err\""));
190        assert!(line.contains("err=\"simulated failure\""));
191        assert!(line.contains("tab_name=\"scrape-cart\""));
192    }
193
194    /// Tiny tracing subscriber that formats each `info!` event into a
195    /// single string and pushes onto a shared Vec. Avoids pulling in a
196    /// formatter crate for tests.
197    struct TestSubscriber {
198        captured: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
199    }
200    impl TestSubscriber {
201        fn new(captured: std::sync::Arc<std::sync::Mutex<Vec<String>>>) -> Self {
202            Self { captured }
203        }
204    }
205    impl tracing::Subscriber for TestSubscriber {
206        fn enabled(&self, _metadata: &tracing::Metadata<'_>) -> bool {
207            true
208        }
209        fn new_span(&self, _span: &tracing::span::Attributes<'_>) -> tracing::span::Id {
210            tracing::span::Id::from_u64(1)
211        }
212        fn record(&self, _span: &tracing::span::Id, _values: &tracing::span::Record<'_>) {}
213        fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {}
214        fn event(&self, event: &tracing::Event<'_>) {
215            let mut visitor = StringVisitor {
216                fields: String::new(),
217            };
218            event.record(&mut visitor);
219            self.captured.lock().unwrap().push(visitor.fields);
220        }
221        fn enter(&self, _span: &tracing::span::Id) {}
222        fn exit(&self, _span: &tracing::span::Id) {}
223    }
224    struct StringVisitor {
225        fields: String,
226    }
227    impl tracing::field::Visit for StringVisitor {
228        fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
229            use std::fmt::Write;
230            let _ = write!(self.fields, "{}={:?} ", field.name(), value);
231        }
232        fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
233            use std::fmt::Write;
234            let _ = write!(self.fields, "{}={:?} ", field.name(), value);
235        }
236        fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
237            use std::fmt::Write;
238            let _ = write!(self.fields, "{}={} ", field.name(), value);
239        }
240    }
241}